Пример #1
0
class Build(models.Model):

    """Build data."""

    project = models.ForeignKey(
        Project,
        verbose_name=_('Project'),
        related_name='builds',
        on_delete=models.CASCADE,
    )
    version = models.ForeignKey(
        Version,
        verbose_name=_('Version'),
        null=True,
        related_name='builds',
        on_delete=models.SET_NULL,
    )
    type = models.CharField(
        _('Type'),
        max_length=55,
        choices=BUILD_TYPES,
        default='html',
    )

    # Describe build state as where in the build process the build is. This
    # allows us to show progression to the user in the form of a progress bar
    # or in the build listing
    state = models.CharField(
        _('State'),
        max_length=55,
        choices=BUILD_STATE,
        default='finished',
        db_index=True,
    )

    # Describe status as *why* the build is in a particular state. It is
    # helpful for communicating more details about state to the user, but it
    # doesn't help describe progression
    # https://github.com/readthedocs/readthedocs.org/pull/7123#issuecomment-635065807
    status = models.CharField(
        _('Status'),
        choices=BUILD_STATUS_CHOICES,
        max_length=32,
        null=True,
        default=None,
        blank=True,
    )
    date = models.DateTimeField(_('Date'), auto_now_add=True, db_index=True)
    success = models.BooleanField(_('Success'), default=True)

    setup = models.TextField(_('Setup'), null=True, blank=True)
    setup_error = models.TextField(_('Setup error'), null=True, blank=True)
    output = models.TextField(_('Output'), default='', blank=True)
    error = models.TextField(_('Error'), default='', blank=True)
    exit_code = models.IntegerField(_('Exit code'), null=True, blank=True)

    # Metadata from were the build happened.
    # This is also used after the version is deleted.
    commit = models.CharField(
        _('Commit'),
        max_length=255,
        null=True,
        blank=True,
    )
    version_slug = models.CharField(
        _('Version slug'),
        max_length=255,
        null=True,
        blank=True,
    )
    version_name = models.CharField(
        _('Version name'),
        max_length=255,
        null=True,
        blank=True,
    )
    version_type = models.CharField(
        _('Version type'),
        max_length=32,
        choices=VERSION_TYPES,
        null=True,
        blank=True,
    )
    _config = JSONField(_('Configuration used in the build'), default=dict)

    length = models.IntegerField(_('Build Length'), null=True, blank=True)

    builder = models.CharField(
        _('Builder'),
        max_length=255,
        null=True,
        blank=True,
    )

    cold_storage = models.NullBooleanField(
        _('Cold Storage'),
        help_text='Build steps stored outside the database.',
    )

    # Managers
    objects = BuildQuerySet.as_manager()
    # Only include BRANCH, TAG, UNKNOWN type Version builds.
    internal = InternalBuildManager.from_queryset(BuildQuerySet)()
    # Only include EXTERNAL type Version builds.
    external = ExternalBuildManager.from_queryset(BuildQuerySet)()

    CONFIG_KEY = '__config'

    class Meta:
        ordering = ['-date']
        get_latest_by = 'date'
        index_together = [
            ['version', 'state', 'type'],
            ['date', 'id'],
        ]
        indexes = [
            models.Index(fields=['project', 'date']),
        ]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._config_changed = False

    @property
    def previous(self):
        """
        Returns the previous build to the current one.

        Matching the project and version.
        """
        date = self.date or timezone.now()
        if self.project is not None and self.version is not None:
            return (
                Build.objects.filter(
                    project=self.project,
                    version=self.version,
                    date__lt=date,
                ).order_by('-date').first()
            )
        return None

    @property
    def config(self):
        """
        Get the config used for this build.

        Since we are saving the config into the JSON field only when it differs
        from the previous one, this helper returns the correct JSON used in this
        Build object (it could be stored in this object or one of the previous
        ones).
        """
        if self.CONFIG_KEY in self._config:
            return (
                Build.objects
                .only('_config')
                .get(pk=self._config[self.CONFIG_KEY])
                ._config
            )
        return self._config

    @config.setter
    def config(self, value):
        """
        Set `_config` to value.

        `_config` should never be set directly from outside the class.
        """
        self._config = value
        self._config_changed = True

    def save(self, *args, **kwargs):  # noqa
        """
        Save object.

        To save space on the db we only save the config if it's different
        from the previous one.

        If the config is the same, we save the pk of the object
        that has the **real** config under the `CONFIG_KEY` key.
        """
        if self.pk is None or self._config_changed:
            previous = self.previous
            if (
                previous is not None
                and self._config
                and self._config == previous.config
            ):
                previous_pk = previous._config.get(self.CONFIG_KEY, previous.pk)
                self._config = {self.CONFIG_KEY: previous_pk}

        if self.version:
            self.version_name = self.version.verbose_name
            self.version_slug = self.version.slug
            self.version_type = self.version.type
        super().save(*args, **kwargs)
        self._config_changed = False

    def __str__(self):
        return ugettext(
            'Build {project} for {usernames} ({pk})'.format(
                project=self.project,
                usernames=' '.join(
                    self.project.users.all().values_list('username', flat=True),
                ),
                pk=self.pk,
            ),
        )

    def get_absolute_url(self):
        return reverse('builds_detail', args=[self.project.slug, self.pk])

    def get_full_url(self):
        """
        Get full url of the build including domain.

        Example: https://readthedocs.org/projects/pip/builds/99999999/
        """
        scheme = 'http' if settings.DEBUG else 'https'
        full_url = '{scheme}://{domain}{absolute_url}'.format(
            scheme=scheme,
            domain=settings.PRODUCTION_DOMAIN,
            absolute_url=self.get_absolute_url()
        )
        return full_url

    def get_version_name(self):
        if self.version:
            return self.version.verbose_name
        return self.version_name

    def get_version_slug(self):
        if self.version:
            return self.version.verbose_name
        return self.version_name

    def get_version_type(self):
        if self.version:
            return self.version.type
        return self.version_type

    @property
    def vcs_url(self):
        if self.version:
            return self.version.vcs_url
        return get_vcs_url(
            project=self.project,
            version_type=self.get_version_type(),
            version_name=self.get_version_name(),
        )

    def get_commit_url(self):
        """Return the commit URL."""
        repo_url = self.project.repo
        if self.is_external:
            if 'github' in repo_url:
                user, repo = get_github_username_repo(repo_url)
                if not user and not repo:
                    return ''

                return GITHUB_PULL_REQUEST_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    number=self.get_version_name(),
                    commit=self.commit
                )
            if 'gitlab' in repo_url:
                user, repo = get_gitlab_username_repo(repo_url)
                if not user and not repo:
                    return ''

                return GITLAB_MERGE_REQUEST_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    number=self.get_version_name(),
                    commit=self.commit
                )
            # TODO: Add External Version Commit URL for BitBucket.
        else:
            if 'github' in repo_url:
                user, repo = get_github_username_repo(repo_url)
                if not user and not repo:
                    return ''

                return GITHUB_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    commit=self.commit
                )
            if 'gitlab' in repo_url:
                user, repo = get_gitlab_username_repo(repo_url)
                if not user and not repo:
                    return ''

                return GITLAB_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    commit=self.commit
                )
            if 'bitbucket' in repo_url:
                user, repo = get_bitbucket_username_repo(repo_url)
                if not user and not repo:
                    return ''

                return BITBUCKET_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    commit=self.commit
                )

        return None

    @property
    def finished(self):
        """Return if build has a finished state."""
        return self.state == BUILD_STATE_FINISHED

    @property
    def is_stale(self):
        """Return if build state is triggered & date more than 5m ago."""
        mins_ago = timezone.now() - datetime.timedelta(minutes=5)
        return self.state == BUILD_STATE_TRIGGERED and self.date < mins_ago

    @property
    def is_external(self):
        type = self.version_type
        if self.version:
            type = self.version.type
        return type == EXTERNAL

    @property
    def external_version_name(self):
        if self.is_external:
            if self.project.git_provider_name == GITHUB_BRAND:
                return GITHUB_EXTERNAL_VERSION_NAME

            if self.project.git_provider_name == GITLAB_BRAND:
                return GITLAB_EXTERNAL_VERSION_NAME

            # TODO: Add External Version Name for BitBucket.
            return GENERIC_EXTERNAL_VERSION_NAME
        return None

    def using_latest_config(self):
        return int(self.config.get('version', '1')) == LATEST_CONFIGURATION_VERSION

    def reset(self):
        """
        Reset the build so it can be re-used when re-trying.

        Dates and states are usually overridden by the build,
        we care more about deleting the commands.
        """
        self.state = BUILD_STATE_TRIGGERED
        self.status = ''
        self.success = True
        self.output = ''
        self.error = ''
        self.exit_code = None
        self.builder = ''
        self.cold_storage = False
        self.commands.all().delete()
        self.save()
Пример #2
0
class Build(models.Model):
    """Build data."""

    project = models.ForeignKey(
        Project,
        verbose_name=_('Project'),
        related_name='builds',
    )
    version = models.ForeignKey(
        Version,
        verbose_name=_('Version'),
        null=True,
        related_name='builds',
    )
    type = models.CharField(
        _('Type'),
        max_length=55,
        choices=BUILD_TYPES,
        default='html',
    )
    state = models.CharField(
        _('State'),
        max_length=55,
        choices=BUILD_STATE,
        default='finished',
    )
    date = models.DateTimeField(_('Date'), auto_now_add=True)
    success = models.BooleanField(_('Success'), default=True)

    setup = models.TextField(_('Setup'), null=True, blank=True)
    setup_error = models.TextField(_('Setup error'), null=True, blank=True)
    output = models.TextField(_('Output'), default='', blank=True)
    error = models.TextField(_('Error'), default='', blank=True)
    exit_code = models.IntegerField(_('Exit code'), null=True, blank=True)
    commit = models.CharField(
        _('Commit'),
        max_length=255,
        null=True,
        blank=True,
    )
    _config = JSONField(_('Configuration used in the build'), default=dict)

    length = models.IntegerField(_('Build Length'), null=True, blank=True)

    builder = models.CharField(
        _('Builder'),
        max_length=255,
        null=True,
        blank=True,
    )

    cold_storage = models.NullBooleanField(
        _('Cold Storage'),
        help_text='Build steps stored outside the database.',
    )

    # Managers
    objects = BuildManager.from_queryset(BuildQuerySet)()
    # Only include BRANCH, TAG, UNKONWN type Version builds.
    internal = InternalBuildManager.from_queryset(BuildQuerySet)()
    # Only include EXTERNAL type Version builds.
    external = ExternalBuildManager.from_queryset(BuildQuerySet)()

    CONFIG_KEY = '__config'

    class Meta:
        ordering = ['-date']
        get_latest_by = 'date'
        index_together = [['version', 'state', 'type']]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._config_changed = False

    @property
    def previous(self):
        """
        Returns the previous build to the current one.

        Matching the project and version.
        """
        date = self.date or timezone.now()
        if self.project is not None and self.version is not None:
            return (Build.objects.filter(
                project=self.project,
                version=self.version,
                date__lt=date,
            ).order_by('-date').first())
        return None

    @property
    def config(self):
        """
        Get the config used for this build.

        Since we are saving the config into the JSON field only when it differs
        from the previous one, this helper returns the correct JSON used in this
        Build object (it could be stored in this object or one of the previous
        ones).
        """
        if self.CONFIG_KEY in self._config:
            return (Build.objects.only('_config').get(
                pk=self._config[self.CONFIG_KEY])._config)
        return self._config

    @config.setter
    def config(self, value):
        """
        Set `_config` to value.

        `_config` should never be set directly from outside the class.
        """
        self._config = value
        self._config_changed = True

    def save(self, *args, **kwargs):  # noqa
        """
        Save object.

        To save space on the db we only save the config if it's different
        from the previous one.

        If the config is the same, we save the pk of the object
        that has the **real** config under the `CONFIG_KEY` key.
        """
        if self.pk is None or self._config_changed:
            previous = self.previous
            # yapf: disable
            if (
                previous is not None and self._config and
                self._config == previous.config
            ):
                # yapf: enable
                previous_pk = previous._config.get(self.CONFIG_KEY,
                                                   previous.pk)
                self._config = {self.CONFIG_KEY: previous_pk}
        super().save(*args, **kwargs)
        self._config_changed = False

    def __str__(self):
        return ugettext(
            'Build {project} for {usernames} ({pk})'.format(
                project=self.project,
                usernames=' '.join(
                    self.project.users.all().values_list('username',
                                                         flat=True), ),
                pk=self.pk,
            ), )

    def get_absolute_url(self):
        return reverse('builds_detail', args=[self.project.slug, self.pk])

    def get_full_url(self):
        """
        Get full url of the build including domain.

        Example: https://readthedocs.org/projects/pip/builds/99999999/
        """
        scheme = 'http' if settings.DEBUG else 'https'
        full_url = '{scheme}://{domain}{absolute_url}'.format(
            scheme=scheme,
            domain=settings.PRODUCTION_DOMAIN,
            absolute_url=self.get_absolute_url())
        return full_url

    def get_commit_url(self):
        """Return the commit URL."""
        repo_url = self.project.repo
        if self.is_external:
            if 'github' in repo_url:
                user, repo = get_github_username_repo(repo_url)
                if not user and not repo:
                    return ''

                repo = repo.rstrip('/')
                return GITHUB_PULL_REQUEST_COMMIT_URL.format(
                    user=user,
                    repo=repo,
                    number=self.version.verbose_name,
                    commit=self.commit)
            # TODO: Add External Version Commit URL for other Git Providers
        else:
            if 'github' in repo_url:
                user, repo = get_github_username_repo(repo_url)
                if not user and not repo:
                    return ''

                repo = repo.rstrip('/')
                return GITHUB_COMMIT_URL.format(user=user,
                                                repo=repo,
                                                commit=self.commit)
            if 'gitlab' in repo_url:
                user, repo = get_gitlab_username_repo(repo_url)
                if not user and not repo:
                    return ''

                repo = repo.rstrip('/')
                return GITLAB_COMMIT_URL.format(user=user,
                                                repo=repo,
                                                commit=self.commit)
            if 'bitbucket' in repo_url:
                user, repo = get_bitbucket_username_repo(repo_url)
                if not user and not repo:
                    return ''

                repo = repo.rstrip('/')
                return BITBUCKET_COMMIT_URL.format(user=user,
                                                   repo=repo,
                                                   commit=self.commit)

        return None

    @property
    def finished(self):
        """Return if build has a finished state."""
        return self.state == BUILD_STATE_FINISHED

    @property
    def is_stale(self):
        """Return if build state is triggered & date more than 5m ago."""
        mins_ago = timezone.now() - datetime.timedelta(minutes=5)
        return self.state == BUILD_STATE_TRIGGERED and self.date < mins_ago

    @property
    def is_external(self):
        return self.version.type == EXTERNAL

    @property
    def external_version_name(self):
        if self.is_external:
            try:
                if self.project.remote_repository.account.provider == 'github':
                    return GITHUB_EXTERNAL_VERSION_NAME
                # TODO: Add External Version Name for other Git Providers
            except RemoteRepository.DoesNotExist:
                log.info('Remote repository does not exist for %s',
                         self.project)
                return GENERIC_EXTERNAL_VERSION_NAME
            except Exception:
                log.exception(
                    'Unhandled exception raised for %s while getting external_version_name',
                    self.project)
                return GENERIC_EXTERNAL_VERSION_NAME
        return None

    def using_latest_config(self):
        return int(self.config.get('version',
                                   '1')) == LATEST_CONFIGURATION_VERSION