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', ) # 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) 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 = BuildManager.from_queryset(BuildQuerySet)() # 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'], ] 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
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