diff --git a/readthedocs/builds/migrations/0066_add_buildconfig_model.py b/readthedocs/builds/migrations/0066_add_buildconfig_model.py new file mode 100644 index 00000000000..91911edbfa7 --- /dev/null +++ b/readthedocs/builds/migrations/0066_add_buildconfig_model.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.9 on 2025-12-09 17:33 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations +from django.db import models +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy() + + dependencies = [ + ("builds", "0065_task_executed_at"), + ] + + operations = [ + migrations.CreateModel( + name="BuildConfig", + 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" + ), + ), + ( + "data", + models.JSONField( + help_text="The rendered YAML configuration used in the build", + unique=True, + verbose_name="Configuration data", + ), + ), + ], + options={ + "verbose_name": "Build configuration", + "verbose_name_plural": "Build configurations", + }, + ), + migrations.AddField( + model_name="build", + name="readthedocs_yaml_data", + field=models.ForeignKey( + blank=True, + help_text="The rendered YAML configuration used in the build", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="builds", + to="builds.buildconfig", + verbose_name="Build configuration data", + ), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 99669386a31..5af3ff1e290 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -607,6 +607,28 @@ def save(self, *args, **kwargs): return 0 +class BuildConfig(TimeStampedModel): + """ + Build configuration data. + + Stores the rendered YAML configuration used in builds. + The unique constraint ensures we don't duplicate identical configs. + """ + + data = models.JSONField( + _("Configuration data"), + unique=True, + help_text=_("The rendered YAML configuration used in the build"), + ) + + class Meta: + verbose_name = _("Build configuration") + verbose_name_plural = _("Build configurations") + + def __str__(self): + return f"BuildConfig {self.pk}" + + class Build(models.Model): """Build data.""" @@ -698,6 +720,15 @@ class Build(models.Model): null=True, blank=True, ) + readthedocs_yaml_data = models.ForeignKey( + "BuildConfig", + verbose_name=_("Build configuration data"), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="builds", + help_text=_("The rendered YAML configuration used in the build"), + ) readthedocs_yaml_path = models.CharField( _("Custom build configuration file path used in this build"), max_length=1024, @@ -823,12 +854,34 @@ def save(self, *args, **kwargs): # noqa If the config is the same, we save the pk of the object that has the **real** config under the `CONFIG_KEY` key. + + Additionally, we create or get a BuildConfig object for the new + readthedocs_yaml_data field to facilitate the migration to the new model. """ 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} + # When using reference style, follow the reference to get the actual config + # and create/get the corresponding BuildConfig + if previous.readthedocs_yaml_data: + # Previous build already has a BuildConfig, reuse it + self.readthedocs_yaml_data = previous.readthedocs_yaml_data + else: + # Previous build doesn't have BuildConfig yet, get its actual config + actual_config = previous.config + if actual_config: + build_config, created = BuildConfig.objects.get_or_create( + data=actual_config + ) + self.readthedocs_yaml_data = build_config + elif self._config and self.CONFIG_KEY not in self._config: + # Populate the new readthedocs_yaml_data field + # We only create a BuildConfig when we have actual config data (not a reference) + # Use get_or_create to avoid duplicates and leverage the unique constraint + build_config, created = BuildConfig.objects.get_or_create(data=self._config) + self.readthedocs_yaml_data = build_config if self.version: self.version_name = self.version.verbose_name diff --git a/readthedocs/builds/tests/test_buildconfig.py b/readthedocs/builds/tests/test_buildconfig.py new file mode 100644 index 00000000000..4dad487237d --- /dev/null +++ b/readthedocs/builds/tests/test_buildconfig.py @@ -0,0 +1,104 @@ +"""Tests for BuildConfig model and Build.readthedocs_yaml_data field.""" + +import django_dynamic_fixture as fixture +import pytest + +from readthedocs.builds.models import Build +from readthedocs.builds.models import BuildConfig +from readthedocs.builds.models import Version +from readthedocs.projects.models import Project + + +@pytest.mark.django_db +class TestBuildReadthedocsYamlData: + """Test Build.readthedocs_yaml_data field and integration.""" + + def test_build_saves_with_config_creates_buildconfig(self): + """Test that saving a Build with config creates BuildConfig.""" + project = fixture.get(Project) + config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}} + + build = fixture.get(Build, project=project) + build.config = config_data + build.save() + + # Check that both old and new fields are populated + assert build._config == config_data + assert build.readthedocs_yaml_data.data == config_data + + def test_build_with_same_config_reuses_buildconfig(self): + """Test that builds with same config reuse the same BuildConfig.""" + project = fixture.get(Project) + config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}} + + # Create first build + build1 = fixture.get(Build, project=project) + build1.config = config_data + build1.save() + + # Create second build with same config + build2 = fixture.get(Build, project=project) + build2.config = config_data + build2.save() + + # Both should reference the same BuildConfig + assert build1.readthedocs_yaml_data.pk == build2.readthedocs_yaml_data.pk + assert BuildConfig.objects.count() == 1 + + def test_build_with_different_config_creates_new_buildconfig(self): + """Test that builds with different configs create separate BuildConfigs.""" + project = fixture.get(Project) + config_data1 = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}} + config_data2 = {"build": {"os": "ubuntu-20.04"}, "python": {"version": "3.10"}} + + # Create first build + build1 = fixture.get(Build, project=project) + build1.config = config_data1 + build1.save() + + # Create second build with different config + build2 = fixture.get(Build, project=project) + build2.config = config_data2 + build2.save() + + # Should have different BuildConfigs + assert build1.readthedocs_yaml_data.pk != build2.readthedocs_yaml_data.pk + assert BuildConfig.objects.count() == 2 + + def test_build_without_config_does_not_create_buildconfig(self): + """Test that a Build without config doesn't create a BuildConfig.""" + project = fixture.get(Project) + build = fixture.get(Build, project=project) + + # Build has no config set + build.save() + + assert build._config is None + assert build.readthedocs_yaml_data is None + assert BuildConfig.objects.count() == 0 + + def test_build_with_config_reference_uses_same_buildconfig(self): + """Test that a Build with config reference (old style) reuses the same BuildConfig.""" + project = fixture.get(Project) + version = fixture.get(Version, project=project) + config_data = {"build": {"os": "ubuntu-22.04"}} + + # Create a build with actual config + build1 = fixture.get(Build, project=project, version=version) + build1.config = config_data + build1.save() + + # Create a build with same config on the same version + # (which will use the reference style in _config) + build2 = fixture.get(Build, project=project, version=version) + build2.config = config_data + build2.save() + + # build2 should have a reference in _config, not actual data + assert Build.CONFIG_KEY in build2._config + # Both builds should have the same BuildConfig + assert build1.readthedocs_yaml_data is not None + assert build2.readthedocs_yaml_data is not None + assert build1.readthedocs_yaml_data.pk == build2.readthedocs_yaml_data.pk + # There should only be one BuildConfig created + assert BuildConfig.objects.count() == 1