Skip to content

Commit 05507be

Browse files
authored
Builds: don't delete them when a version is deleted (#7679)
We lose information when a version is deleted. Setting the version to null allow us to keep the builds around. But now we need to save more data to be able to reproduce some links. A data migration is needed to update old builds, but isn't required, as we fallback to the value from the version. Close #7674
1 parent f50003e commit 05507be

File tree

10 files changed

+268
-78
lines changed

10 files changed

+268
-78
lines changed

readthedocs/api/v2/serializers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ class BuildSerializer(serializers.ModelSerializer):
123123

124124
commands = BuildCommandSerializer(many=True, read_only=True)
125125
project_slug = serializers.ReadOnlyField(source='project.slug')
126-
version_slug = serializers.ReadOnlyField(source='version.slug')
127-
docs_url = serializers.ReadOnlyField(source='version.get_absolute_url')
126+
version_slug = serializers.ReadOnlyField(source='get_version_slug')
127+
docs_url = serializers.SerializerMethodField()
128128
state_display = serializers.ReadOnlyField(source='get_state_display')
129129
commit_url = serializers.ReadOnlyField(source='get_commit_url')
130130
# Jsonfield needs an explicit serializer
@@ -136,6 +136,11 @@ class Meta:
136136
# `_config` should be excluded to avoid conflicts with `config`
137137
exclude = ('builder', '_config')
138138

139+
def get_docs_url(self, obj):
140+
if obj.version:
141+
return obj.version.get_absolute_url()
142+
return None
143+
139144

140145
class BuildAdminSerializer(BuildSerializer):
141146

readthedocs/api/v3/serializers.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,16 @@ def get__self(self, obj):
6969
return self._absolute_url(path)
7070

7171
def get_version(self, obj):
72-
path = reverse(
73-
'projects-versions-detail',
74-
kwargs={
75-
'parent_lookup_project__slug': obj.project.slug,
76-
'version_slug': obj.version.slug,
77-
},
78-
)
79-
return self._absolute_url(path)
72+
if obj.version:
73+
path = reverse(
74+
'projects-versions-detail',
75+
kwargs={
76+
'parent_lookup_project__slug': obj.project.slug,
77+
'version_slug': obj.version.slug,
78+
},
79+
)
80+
return self._absolute_url(path)
81+
return None
8082

8183
def get_project(self, obj):
8284
path = reverse(
@@ -103,14 +105,16 @@ def get_project(self, obj):
103105
return self._absolute_url(path)
104106

105107
def get_version(self, obj):
106-
path = reverse(
107-
'project_version_detail',
108-
kwargs={
109-
'project_slug': obj.project.slug,
110-
'version_slug': obj.version.slug
111-
}
112-
)
113-
return self._absolute_url(path)
108+
if obj.version:
109+
path = reverse(
110+
'project_version_detail',
111+
kwargs={
112+
'project_slug': obj.project.slug,
113+
'version_slug': obj.version.slug
114+
}
115+
)
116+
return self._absolute_url(path)
117+
return None
114118

115119

116120
class BuildConfigSerializer(FlexFieldsSerializerMixin, serializers.Serializer):
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 2.2.16 on 2020-11-18 21:52
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('builds', '0030_add_automation_rule_matches'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='build',
16+
name='version_name',
17+
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Version name'),
18+
),
19+
migrations.AddField(
20+
model_name='build',
21+
name='version_slug',
22+
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Version slug'),
23+
),
24+
migrations.AddField(
25+
model_name='build',
26+
name='version_type',
27+
field=models.CharField(blank=True, choices=[('branch', 'Branch'), ('tag', 'Tag'), ('external', 'External'), ('unknown', 'Unknown')], max_length=32, null=True, verbose_name='Version type'),
28+
),
29+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 2.2.17 on 2020-11-25 15:56
2+
3+
from django.db import migrations
4+
5+
6+
def forwards_func(apps, schema_editor):
7+
"""Migrate all data from versions to builds."""
8+
Build = apps.get_model('builds', 'Build')
9+
for build in Build.objects.all().iterator():
10+
# When the build is saved, the fields are updated.
11+
build.save()
12+
13+
14+
class Migration(migrations.Migration):
15+
16+
dependencies = [
17+
('builds', '0031_add_version_fields_to_build'),
18+
]
19+
20+
operations = [
21+
migrations.RunPython(forwards_func),
22+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 2.2.17 on 2020-11-25 16:01
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('builds', '0032_migrate_version_data_to_build'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='build',
16+
name='version',
17+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds', to='builds.Version', verbose_name='Version'),
18+
),
19+
]

readthedocs/builds/models.py

Lines changed: 79 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
get_bitbucket_username_repo,
6464
get_github_username_repo,
6565
get_gitlab_username_repo,
66+
get_vcs_url,
6667
)
6768
from readthedocs.builds.version_slug import VersionSlugField
6869
from readthedocs.config import LATEST_CONFIGURATION_VERSION
@@ -74,12 +75,10 @@
7475
GITHUB_BRAND,
7576
GITHUB_COMMIT_URL,
7677
GITHUB_PULL_REQUEST_COMMIT_URL,
77-
GITHUB_PULL_REQUEST_URL,
7878
GITHUB_URL,
7979
GITLAB_BRAND,
8080
GITLAB_COMMIT_URL,
8181
GITLAB_MERGE_REQUEST_COMMIT_URL,
82-
GITLAB_MERGE_REQUEST_URL,
8382
GITLAB_URL,
8483
MEDIA_TYPES,
8584
PRIVACY_CHOICES,
@@ -213,47 +212,22 @@ def ref(self):
213212

214213
@property
215214
def vcs_url(self):
216-
"""
217-
Generate VCS (github, gitlab, bitbucket) URL for this version.
218-
219-
Example: https://github.com/rtfd/readthedocs.org/tree/3.4.2/.
220-
External Version Example: https://github.com/rtfd/readthedocs.org/pull/99/.
221-
"""
222-
if self.type == EXTERNAL:
223-
if 'github' in self.project.repo:
224-
user, repo = get_github_username_repo(self.project.repo)
225-
return GITHUB_PULL_REQUEST_URL.format(
226-
user=user,
227-
repo=repo,
228-
number=self.verbose_name,
229-
)
230-
if 'gitlab' in self.project.repo:
231-
user, repo = get_gitlab_username_repo(self.project.repo)
232-
return GITLAB_MERGE_REQUEST_URL.format(
233-
user=user,
234-
repo=repo,
235-
number=self.verbose_name,
236-
)
237-
# TODO: Add VCS URL for BitBucket.
238-
return ''
239-
240-
url = ''
241-
if self.slug == STABLE:
242-
slug_url = self.ref
243-
elif self.slug == LATEST:
244-
slug_url = self.project.get_default_branch()
245-
else:
246-
slug_url = self.slug
247-
248-
if ('github' in self.project.repo) or ('gitlab' in self.project.repo):
249-
url = f'/tree/{slug_url}/'
250-
251-
if 'bitbucket' in self.project.repo:
252-
slug_url = self.identifier
253-
url = f'/src/{slug_url}'
254-
255-
# TODO: improve this replacing
256-
return self.project.repo.replace('git://', 'https://').replace('.git', '') + url
215+
version_name = self.verbose_name
216+
if not self.is_external:
217+
if self.slug == STABLE:
218+
version_name = self.ref
219+
elif self.slug == LATEST:
220+
version_name = self.project.get_default_branch()
221+
else:
222+
version_name = self.slug
223+
if 'bitbucket' in self.project.repo:
224+
version_name = self.identifier
225+
226+
return get_vcs_url(
227+
project=self.project,
228+
version_type=self.type,
229+
version_name=version_name,
230+
)
257231

258232
@property
259233
def last_build(self):
@@ -622,7 +596,7 @@ class Build(models.Model):
622596
verbose_name=_('Version'),
623597
null=True,
624598
related_name='builds',
625-
on_delete=models.CASCADE,
599+
on_delete=models.SET_NULL,
626600
)
627601
type = models.CharField(
628602
_('Type'),
@@ -661,12 +635,34 @@ class Build(models.Model):
661635
output = models.TextField(_('Output'), default='', blank=True)
662636
error = models.TextField(_('Error'), default='', blank=True)
663637
exit_code = models.IntegerField(_('Exit code'), null=True, blank=True)
638+
639+
# Metadata from were the build happened.
640+
# This is also used after the version is deleted.
664641
commit = models.CharField(
665642
_('Commit'),
666643
max_length=255,
667644
null=True,
668645
blank=True,
669646
)
647+
version_slug = models.CharField(
648+
_('Version slug'),
649+
max_length=255,
650+
null=True,
651+
blank=True,
652+
)
653+
version_name = models.CharField(
654+
_('Version name'),
655+
max_length=255,
656+
null=True,
657+
blank=True,
658+
)
659+
version_type = models.CharField(
660+
_('Version type'),
661+
max_length=32,
662+
choices=VERSION_TYPES,
663+
null=True,
664+
blank=True,
665+
)
670666
_config = JSONField(_('Configuration used in the build'), default=dict)
671667

672668
length = models.IntegerField(_('Build Length'), null=True, blank=True)
@@ -763,14 +759,18 @@ def save(self, *args, **kwargs): # noqa
763759
"""
764760
if self.pk is None or self._config_changed:
765761
previous = self.previous
766-
# yapf: disable
767762
if (
768-
previous is not None and self._config and
769-
self._config == previous.config
763+
previous is not None
764+
and self._config
765+
and self._config == previous.config
770766
):
771-
# yapf: enable
772767
previous_pk = previous._config.get(self.CONFIG_KEY, previous.pk)
773768
self._config = {self.CONFIG_KEY: previous_pk}
769+
770+
if self.version:
771+
self.version_name = self.version.verbose_name
772+
self.version_slug = self.version.slug
773+
self.version_type = self.version.type
774774
super().save(*args, **kwargs)
775775
self._config_changed = False
776776

@@ -802,6 +802,31 @@ def get_full_url(self):
802802
)
803803
return full_url
804804

805+
def get_version_name(self):
806+
if self.version:
807+
return self.version.verbose_name
808+
return self.version_name
809+
810+
def get_version_slug(self):
811+
if self.version:
812+
return self.version.verbose_name
813+
return self.version_name
814+
815+
def get_version_type(self):
816+
if self.version:
817+
return self.version.type
818+
return self.version_type
819+
820+
@property
821+
def vcs_url(self):
822+
if self.version:
823+
return self.version.vcs_url
824+
return get_vcs_url(
825+
project=self.project,
826+
version_type=self.get_version_type(),
827+
version_name=self.get_version_name(),
828+
)
829+
805830
def get_commit_url(self):
806831
"""Return the commit URL."""
807832
repo_url = self.project.repo
@@ -814,7 +839,7 @@ def get_commit_url(self):
814839
return GITHUB_PULL_REQUEST_COMMIT_URL.format(
815840
user=user,
816841
repo=repo,
817-
number=self.version.verbose_name,
842+
number=self.get_version_name(),
818843
commit=self.commit
819844
)
820845
if 'gitlab' in repo_url:
@@ -825,7 +850,7 @@ def get_commit_url(self):
825850
return GITLAB_MERGE_REQUEST_COMMIT_URL.format(
826851
user=user,
827852
repo=repo,
828-
number=self.version.verbose_name,
853+
number=self.get_version_name(),
829854
commit=self.commit
830855
)
831856
# TODO: Add External Version Commit URL for BitBucket.
@@ -876,7 +901,10 @@ def is_stale(self):
876901

877902
@property
878903
def is_external(self):
879-
return self.version.type == EXTERNAL
904+
type = self.version_type
905+
if self.version:
906+
type = self.version.type
907+
return type == EXTERNAL
880908

881909
@property
882910
def external_version_name(self):

0 commit comments

Comments
 (0)