class Version(models.Model): """Version of a ``Project``.""" project = models.ForeignKey( Project, verbose_name=_('Project'), related_name='versions', ) type = models.CharField( _('Type'), max_length=20, choices=VERSION_TYPES, default='unknown', ) # used by the vcs backend #: The identifier is the ID for the revision this is version is for. This #: might be the revision number (e.g. in SVN), or the commit hash (e.g. in #: Git). If the this version is pointing to a branch, then ``identifier`` #: will contain the branch name. identifier = models.CharField(_('Identifier'), max_length=255) #: This is the actual name that we got for the commit stored in #: ``identifier``. This might be the tag or branch name like ``"v1.0.4"``. #: However this might also hold special version names like ``"latest"`` #: and ``"stable"``. verbose_name = models.CharField(_('Verbose Name'), max_length=255) #: The slug is the slugified version of ``verbose_name`` that can be used #: in the URL to identify this version in a project. It's also used in the #: filesystem to determine how the paths for this version are called. It #: must not be used for any other identifying purposes. slug = VersionSlugField( _('Slug'), max_length=255, populate_from='verbose_name', ) supported = models.BooleanField(_('Supported'), default=True) active = models.BooleanField(_('Active'), default=False) built = models.BooleanField(_('Built'), default=False) uploaded = models.BooleanField(_('Uploaded'), default=False) privacy_level = models.CharField( _('Privacy Level'), max_length=20, choices=PRIVACY_CHOICES, default=settings.DEFAULT_VERSION_PRIVACY_LEVEL, help_text=_('Level of privacy for this Version.'), ) machine = models.BooleanField(_('Machine Created'), default=False) objects = VersionManager.from_queryset(VersionQuerySet)() # Only include BRANCH, TAG, UNKNOWN type Versions. internal = InternalVersionManager.from_queryset(VersionQuerySet)() # Only include EXTERNAL type Versions. external = ExternalVersionManager.from_queryset(VersionQuerySet)() class Meta: unique_together = [('project', 'slug')] ordering = ['-verbose_name'] permissions = ( # Translators: Permission around whether a user can view the # version ('view_version', _('View Version')), ) def __str__(self): return ugettext( 'Version {version} of {project} ({pk})'.format( version=self.verbose_name, project=self.project, pk=self.pk, ), ) @property def ref(self): if self.slug == STABLE: stable = determine_stable_version( self.project.versions(manager=INTERNAL).all()) if stable: return stable.slug @property def vcs_url(self): """ Generate VCS (github, gitlab, bitbucket) URL for this version. Example: https://github.com/rtfd/readthedocs.org/tree/3.4.2/. External Version Example: https://github.com/rtfd/readthedocs.org/pull/99/. """ if self.type == EXTERNAL: if 'github' in self.project.repo: user, repo = get_github_username_repo(self.project.repo) return GITHUB_PULL_REQUEST_URL.format( user=user, repo=repo, number=self.verbose_name, ) if 'gitlab' in self.project.repo: user, repo = get_gitlab_username_repo(self.project.repo) return GITLAB_MERGE_REQUEST_URL.format( user=user, repo=repo, number=self.verbose_name, ) # TODO: Add VCS URL for BitBucket. return '' url = '' if self.slug == STABLE: slug_url = self.ref elif self.slug == LATEST: slug_url = self.project.default_branch or self.project.vcs_repo( ).fallback_branch else: slug_url = self.slug if ('github' in self.project.repo) or ('gitlab' in self.project.repo): url = f'/tree/{slug_url}/' if 'bitbucket' in self.project.repo: slug_url = self.identifier url = f'/src/{slug_url}' # TODO: improve this replacing return self.project.repo.replace('git://', 'https://').replace( '.git', '') + url @property def last_build(self): return self.builds.order_by('-date').first() @property def config(self): """ Proxy to the configuration of the build. :returns: The configuration used in the last successful build. :rtype: dict """ last_build = (self.builds(manager=INTERNAL).filter( state=BUILD_STATE_FINISHED, success=True, ).order_by('-date').only('_config').first()) return last_build.config @property def commit_name(self): """ Return the branch name, the tag name or the revision identifier. The result could be used as ref in a git repo, e.g. for linking to GitHub, Bitbucket or GitLab. """ # LATEST is special as it is usually a branch but does not contain the # name in verbose_name. if self.slug == LATEST: if self.project.default_branch: return self.project.default_branch return self.project.vcs_repo().fallback_branch if self.slug == STABLE: if self.type == BRANCH: # Special case, as we do not store the original branch name # that the stable version works on. We can only interpolate the # name from the commit identifier, but it's hacky. # TODO: Refactor ``Version`` to store more actual info about # the underlying commits. if self.identifier.startswith('origin/'): return self.identifier[len('origin/'):] return self.identifier # By now we must have handled all special versions. if self.slug in NON_REPOSITORY_VERSIONS: raise Exception('All special versions must be handled by now.') if self.type in (BRANCH, TAG): # If this version is a branch or a tag, the verbose_name will # contain the actual name. We cannot use identifier as this might # include the "origin/..." part in the case of a branch. A tag # would contain the hash in identifier, which is not as pretty as # the actual tag name. return self.verbose_name if self.type == EXTERNAL: # If this version is a EXTERNAL version, the identifier will # contain the actual commit hash. which we can use to # generate url for a given file name return self.identifier # If we came that far it's not a special version # nor a branch, tag or EXTERNAL version. # Therefore just return the identifier to make a safe guess. log.debug( 'TODO: Raise an exception here. Testing what cases it happens', ) return self.identifier def get_absolute_url(self): # Hack external versions for now. # TODO: We can integrate them into the resolver # but this is much simpler to handle since we only link them a couple places for now if self.type == EXTERNAL: # Django's static file serving doesn't automatically append index.html url = f'{settings.EXTERNAL_VERSION_URL}/html/' \ f'{self.project.slug}/{self.slug}/index.html' return url if not self.built and not self.uploaded: return reverse( 'project_version_detail', kwargs={ 'project_slug': self.project.slug, 'version_slug': self.slug, }, ) private = self.privacy_level == PRIVATE return self.project.get_docs_url( version_slug=self.slug, private=private, ) def save(self, *args, **kwargs): # pylint: disable=arguments-differ """Add permissions to the Version for all owners on save.""" from readthedocs.projects import tasks obj = super().save(*args, **kwargs) broadcast( type='app', task=tasks.symlink_project, args=[self.project.pk], ) return obj def delete(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects import tasks log.info('Removing files for version %s', self.slug) broadcast( type='app', task=tasks.remove_dirs, args=[self.get_artifact_paths()], ) # Remove build artifacts from storage if the version is not external if self.type != EXTERNAL: storage_paths = self.get_storage_paths() tasks.remove_build_storage_paths.delay(storage_paths) project_pk = self.project.pk super().delete(*args, **kwargs) broadcast( type='app', task=tasks.symlink_project, args=[project_pk], ) @property def identifier_friendly(self): """Return display friendly identifier.""" if re.match(r'^[0-9a-f]{40}$', self.identifier, re.I): return self.identifier[:8] return self.identifier @property def is_editable(self): return self.type == BRANCH @property def supports_wipe(self): """Return True if version is not external.""" return not self.type == EXTERNAL def get_subdomain_url(self): private = self.privacy_level == PRIVATE return self.project.get_docs_url( version_slug=self.slug, lang_slug=self.project.language, private=private, ) def get_downloads(self, pretty=False): project = self.project data = {} def prettify(k): return k if pretty else k.lower() if project.has_pdf(self.slug, version_type=self.type): data[prettify('PDF')] = project.get_production_media_url( 'pdf', self.slug, ) if project.has_htmlzip(self.slug, version_type=self.type): data[prettify('HTML')] = project.get_production_media_url( 'htmlzip', self.slug, ) if project.has_epub(self.slug, version_type=self.type): data[prettify('Epub')] = project.get_production_media_url( 'epub', self.slug, ) return data def get_conf_py_path(self): conf_py_path = self.project.conf_dir(self.slug) checkout_prefix = self.project.checkout_path(self.slug) conf_py_path = os.path.relpath(conf_py_path, checkout_prefix) return conf_py_path def get_build_path(self): """Return version build path if path exists, otherwise `None`.""" path = self.project.checkout_path(version=self.slug) if os.path.exists(path): return path return None def get_artifact_paths(self): """ Return a list of all production artifacts/media path for this version. :rtype: list """ paths = [] for type_ in ('pdf', 'epub', 'htmlzip'): paths.append( self.project.get_production_media_path( type_=type_, version_slug=self.slug), ) paths.append(self.project.rtd_build_path(version=self.slug)) return paths def get_storage_paths(self): """ Return a list of all build artifact storage paths for this version. :rtype: list """ paths = [] for type_ in MEDIA_TYPES: paths.append( self.project.get_storage_path( type_=type_, version_slug=self.slug, include_file=False, version_type=self.type, )) return paths def clean_build_path(self): """ Clean build path for project version. Ensure build path is clean for project version. Used to ensure stale build checkouts for each project version are removed. """ try: path = self.get_build_path() if path is not None: log.debug('Removing build path %s for %s', path, self) rmtree(path) except OSError: log.exception('Build path cleanup failed') def get_github_url( self, docroot, filename, source_suffix='.rst', action='view', ): """ Return a GitHub URL for a given filename. :param docroot: Location of documentation in repository :param filename: Name of file :param source_suffix: File suffix of documentation format :param action: `view` (default) or `edit` """ repo_url = self.project.repo if 'github' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' if action == 'view': action_string = 'blob' elif action == 'edit': action_string = 'edit' user, repo = get_github_username_repo(repo_url) if not user and not repo: return '' repo = repo.rstrip('/') if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return GITHUB_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, action=action_string, ) def get_gitlab_url( self, docroot, filename, source_suffix='.rst', action='view', ): repo_url = self.project.repo if 'gitlab' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' if action == 'view': action_string = 'blob' elif action == 'edit': action_string = 'edit' user, repo = get_gitlab_username_repo(repo_url) if not user and not repo: return '' repo = repo.rstrip('/') if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return GITLAB_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, action=action_string, ) def get_bitbucket_url(self, docroot, filename, source_suffix='.rst'): repo_url = self.project.repo if 'bitbucket' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' user, repo = get_bitbucket_username_repo(repo_url) if not user and not repo: return '' repo = repo.rstrip('/') if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return BITBUCKET_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, )
class Version(TimeStampedModel): """Version of a ``Project``.""" # Overridden from TimeStampedModel just to allow null values. # TODO: remove after deploy. created = CreationDateTimeField( _('created'), null=True, blank=True, ) modified = ModificationDateTimeField( _('modified'), null=True, blank=True, ) project = models.ForeignKey( Project, verbose_name=_('Project'), related_name='versions', on_delete=models.CASCADE, ) type = models.CharField( _('Type'), max_length=20, choices=VERSION_TYPES, default='unknown', ) # used by the vcs backend #: The identifier is the ID for the revision this is version is for. This #: might be the revision number (e.g. in SVN), or the commit hash (e.g. in #: Git). If the this version is pointing to a branch, then ``identifier`` #: will contain the branch name. identifier = models.CharField(_('Identifier'), max_length=255) #: This is the actual name that we got for the commit stored in #: ``identifier``. This might be the tag or branch name like ``"v1.0.4"``. #: However this might also hold special version names like ``"latest"`` #: and ``"stable"``. verbose_name = models.CharField(_('Verbose Name'), max_length=255) #: The slug is the slugified version of ``verbose_name`` that can be used #: in the URL to identify this version in a project. It's also used in the #: filesystem to determine how the paths for this version are called. It #: must not be used for any other identifying purposes. slug = VersionSlugField( _('Slug'), max_length=255, populate_from='verbose_name', ) supported = models.BooleanField(_('Supported'), default=True) active = models.BooleanField(_('Active'), default=False) built = models.BooleanField(_('Built'), default=False) uploaded = models.BooleanField(_('Uploaded'), default=False) privacy_level = models.CharField( _('Privacy Level'), max_length=20, choices=PRIVACY_CHOICES, default=settings.DEFAULT_VERSION_PRIVACY_LEVEL, help_text=_('Level of privacy for this Version.'), ) hidden = models.BooleanField( _('Hidden'), default=False, help_text=_('Hide this version from the version (flyout) menu and search results?') ) machine = models.BooleanField(_('Machine Created'), default=False) # Whether the latest successful build for this version contains certain media types has_pdf = models.BooleanField(_('Has PDF'), default=False) has_epub = models.BooleanField(_('Has ePub'), default=False) has_htmlzip = models.BooleanField(_('Has HTML Zip'), default=False) documentation_type = models.CharField( _('Documentation type'), max_length=20, choices=DOCTYPE_CHOICES, default=SPHINX, help_text=_( 'Type of documentation the version was built with.' ), ) objects = VersionManager.from_queryset(VersionQuerySet)() # Only include BRANCH, TAG, UNKNOWN type Versions. internal = InternalVersionManager.from_queryset(partial(VersionQuerySet, internal_only=True))() # Only include EXTERNAL type Versions. external = ExternalVersionManager.from_queryset(partial(VersionQuerySet, external_only=True))() class Meta: unique_together = [('project', 'slug')] ordering = ['-verbose_name'] def __str__(self): return ugettext( 'Version {version} of {project} ({pk})'.format( version=self.verbose_name, project=self.project, pk=self.pk, ), ) @property def is_external(self): return self.type == EXTERNAL @property def ref(self): if self.slug == STABLE: stable = determine_stable_version( self.project.versions(manager=INTERNAL).all() ) if stable: return stable.slug @property def vcs_url(self): version_name = self.verbose_name if not self.is_external: if self.slug == STABLE: version_name = self.ref elif self.slug == LATEST: version_name = self.project.get_default_branch() else: version_name = self.slug if 'bitbucket' in self.project.repo: version_name = self.identifier return get_vcs_url( project=self.project, version_type=self.type, version_name=version_name, ) @property def last_build(self): return self.builds.order_by('-date').first() @property def config(self): """ Proxy to the configuration of the build. :returns: The configuration used in the last successful build. :rtype: dict """ last_build = ( self.builds(manager=INTERNAL).filter( state=BUILD_STATE_FINISHED, success=True, ).order_by('-date') .only('_config') .first() ) return last_build.config @property def commit_name(self): """ Return the branch name, the tag name or the revision identifier. The result could be used as ref in a git repo, e.g. for linking to GitHub, Bitbucket or GitLab. """ # LATEST is special as it is usually a branch but does not contain the # name in verbose_name. if self.slug == LATEST: return self.project.get_default_branch() if self.slug == STABLE: if self.type == BRANCH: # Special case, as we do not store the original branch name # that the stable version works on. We can only interpolate the # name from the commit identifier, but it's hacky. # TODO: Refactor ``Version`` to store more actual info about # the underlying commits. if self.identifier.startswith('origin/'): return self.identifier[len('origin/'):] return self.identifier # By now we must have handled all special versions. if self.slug in NON_REPOSITORY_VERSIONS: raise Exception('All special versions must be handled by now.') if self.type in (BRANCH, TAG): # If this version is a branch or a tag, the verbose_name will # contain the actual name. We cannot use identifier as this might # include the "origin/..." part in the case of a branch. A tag # would contain the hash in identifier, which is not as pretty as # the actual tag name. return self.verbose_name if self.type == EXTERNAL: # If this version is a EXTERNAL version, the identifier will # contain the actual commit hash. which we can use to # generate url for a given file name return self.identifier # If we came that far it's not a special version # nor a branch, tag or EXTERNAL version. # Therefore just return the identifier to make a safe guess. log.debug( 'TODO: Raise an exception here. Testing what cases it happens', ) return self.identifier def get_absolute_url(self): """Get absolute url to the docs of the version.""" if not self.built and not self.uploaded: return reverse( 'project_version_detail', kwargs={ 'project_slug': self.project.slug, 'version_slug': self.slug, }, ) external = self.type == EXTERNAL return self.project.get_docs_url( version_slug=self.slug, external=external, ) def delete(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects import tasks log.info('Removing files for version %s', self.slug) tasks.clean_project_resources(self.project, self) super().delete(*args, **kwargs) @property def identifier_friendly(self): """Return display friendly identifier.""" if re.match(r'^[0-9a-f]{40}$', self.identifier, re.I): return self.identifier[:8] return self.identifier @property def is_editable(self): return self.type == BRANCH @property def supports_wipe(self): """Return True if version is not external.""" return self.type != EXTERNAL @property def is_sphinx_type(self): return self.documentation_type in {SPHINX, SPHINX_HTMLDIR, SPHINX_SINGLEHTML} def get_subdomain_url(self): external = self.type == EXTERNAL return self.project.get_docs_url( version_slug=self.slug, lang_slug=self.project.language, external=external, ) def get_downloads(self, pretty=False): project = self.project data = {} def prettify(k): return k if pretty else k.lower() if self.has_pdf: data[prettify('PDF')] = project.get_production_media_url( 'pdf', self.slug, ) if self.has_htmlzip: data[prettify('HTML')] = project.get_production_media_url( 'htmlzip', self.slug, ) if self.has_epub: data[prettify('Epub')] = project.get_production_media_url( 'epub', self.slug, ) return data def get_conf_py_path(self): conf_py_path = self.project.conf_dir(self.slug) checkout_prefix = self.project.checkout_path(self.slug) conf_py_path = os.path.relpath(conf_py_path, checkout_prefix) return conf_py_path def get_build_path(self): """Return version build path if path exists, otherwise `None`.""" path = self.project.checkout_path(version=self.slug) if os.path.exists(path): return path return None def get_storage_paths(self): """ Return a list of all build artifact storage paths for this version. :rtype: list """ paths = [] for type_ in MEDIA_TYPES: paths.append( self.project.get_storage_path( type_=type_, version_slug=self.slug, include_file=False, version_type=self.type, ) ) return paths def get_storage_environment_cache_path(self): """Return the path of the cached environment tar file.""" return build_environment_storage.join(self.project.slug, f'{self.slug}.tar') def clean_build_path(self): """ Clean build path for project version. Ensure build path is clean for project version. Used to ensure stale build checkouts for each project version are removed. """ try: path = self.get_build_path() if path is not None: log.debug('Removing build path %s for %s', path, self) rmtree(path) except OSError: log.exception('Build path cleanup failed') def get_github_url( self, docroot, filename, source_suffix='.rst', action='view', ): """ Return a GitHub URL for a given filename. :param docroot: Location of documentation in repository :param filename: Name of file :param source_suffix: File suffix of documentation format :param action: `view` (default) or `edit` """ repo_url = self.project.repo if 'github' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' if action == 'view': action_string = 'blob' elif action == 'edit': action_string = 'edit' user, repo = get_github_username_repo(repo_url) if not user and not repo: return '' if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return GITHUB_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, action=action_string, ) def get_gitlab_url( self, docroot, filename, source_suffix='.rst', action='view', ): repo_url = self.project.repo if 'gitlab' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' if action == 'view': action_string = 'blob' elif action == 'edit': action_string = 'edit' user, repo = get_gitlab_username_repo(repo_url) if not user and not repo: return '' if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return GITLAB_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, action=action_string, ) def get_bitbucket_url(self, docroot, filename, source_suffix='.rst'): repo_url = self.project.repo if 'bitbucket' not in repo_url: return '' if not docroot: return '' # Normalize /docroot/ docroot = '/' + docroot.strip('/') + '/' user, repo = get_bitbucket_username_repo(repo_url) if not user and not repo: return '' if not filename: # If there isn't a filename, we don't need a suffix source_suffix = '' return BITBUCKET_URL.format( user=user, repo=repo, version=self.commit_name, docroot=docroot, path=filename, source_suffix=source_suffix, )
def test_uniquifying_suffix(self): field = VersionSlugField(populate_from='foo') self.assertEqual(field.uniquifying_suffix(0), '_a') self.assertEqual(field.uniquifying_suffix(25), '_z') self.assertEqual(field.uniquifying_suffix(26), '_ba') self.assertEqual(field.uniquifying_suffix(52), '_ca')