diff --git a/readthedocs/api/v2/utils.py b/readthedocs/api/v2/utils.py index 011a10cd057..1155c385fb1 100644 --- a/readthedocs/api/v2/utils.py +++ b/readthedocs/api/v2/utils.py @@ -28,6 +28,7 @@ def sync_versions_to_db(project, versions, type): - check if user has a ``stable`` / ``latest`` version and disable ours - update old versions with newer configs (identifier, type, machine) + - take into account ``VersionOverride`` for versions modified by the user - create new versions that do not exist on DB (in bulk) - it does not delete versions @@ -77,17 +78,20 @@ def sync_versions_to_db(project, versions, type): # Version is correct continue - # Update slug with new identifier - Version.objects.filter( + # Update slug with new identifier if it differs + v = Version.objects.filter( project=project, verbose_name=version_name, # Always filter by type, a tag and a branch # can share the same verbose_name. type=type, - ).update( - identifier=version_id, - machine=False, - ) + ).first() + + # Update the version with VCS data only if the version is not + # overridden by the user + if v and not v.active or (not v.override or not v.override.user_identifier): + v.machine = False + v.identifier = version_id log.info( "Re-syncing versions: version updated.", diff --git a/readthedocs/builds/forms.py b/readthedocs/builds/forms.py index b5d3084f764..bc67cde05f5 100644 --- a/readthedocs/builds/forms.py +++ b/readthedocs/builds/forms.py @@ -21,6 +21,7 @@ RegexAutomationRule, Version, VersionAutomationRule, + VersionOverride, ) @@ -30,6 +31,8 @@ class Meta: states_fields = ["active", "hidden"] privacy_fields = ["privacy_level"] fields = ( + "slug", + "identifier", *states_fields, *privacy_fields, ) @@ -89,6 +92,15 @@ def _is_default_version(self): return project.default_version == self.instance.slug def save(self, commit=True): + # Recover the original data from DB to save it as backup + version = Version.objects.get(pk=self.instance.pk) + override, _ = VersionOverride.objects.get_or_create(version=version) + override.user_slug = self.instance.slug + override.user_identifier = self.instance.identifier + override.original_slug = version.slug + override.original_identifier = version.identifier + override.save() + obj = super().save(commit=commit) obj.post_save(was_active=self._was_active) return obj diff --git a/readthedocs/builds/migrations/0060_change_slug_identifier.py b/readthedocs/builds/migrations/0060_change_slug_identifier.py new file mode 100644 index 00000000000..eb7e8722b8c --- /dev/null +++ b/readthedocs/builds/migrations/0060_change_slug_identifier.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.16 on 2024-12-02 15:05 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy + + dependencies = [ + ('builds', '0059_add_version_date_index'), + ] + + operations = [ + migrations.CreateModel( + name='VersionOverride', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('original_slug', models.CharField(blank=True, max_length=255, null=True)), + ('user_slug', models.CharField(blank=True, max_length=255, null=True)), + ('original_identifier', models.CharField(blank=True, max_length=255, null=True)), + ('user_identifier', models.CharField(blank=True, max_length=255, null=True)), + ('version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='override', to='builds.version')), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index ffc34333781..e7ba8cf04b6 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -86,6 +86,45 @@ log = structlog.get_logger(__name__) +class VersionOverride(TimeStampedModel): + """ + User-modified ``Version`` of a ``Project``. + + We use this model to store all the fields the user has override from the + original ``Version`` and also to keep those original values. + + This model allows us to perform a re-sync of VCS versions. + """ + + version = models.OneToOneField( + "Version", + related_name="override", + on_delete=models.CASCADE, + ) + # TODO: add validations to `_slug` fields. We can't use `VersionSlugField` + # because it requires the `populate_from` field that we don't need here. + original_slug = models.CharField( + max_length=255, + null=True, + blank=True, + ) + user_slug = models.CharField( + max_length=255, + null=True, + blank=True, + ) + original_identifier = models.CharField( + max_length=255, + null=True, + blank=True, + ) + user_identifier = models.CharField( + max_length=255, + null=True, + blank=True, + ) + + class Version(TimeStampedModel): """Version of a ``Project``."""