Beispiel #1
0
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,
        )
Beispiel #2
0
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,
        )
Beispiel #3
0
 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')