Skip to content

Commit 054385c

Browse files
Copilothumitos
andcommitted
Add BuildConfig model and readthedocs_yaml_data field to Build model
Co-authored-by: humitos <244656+humitos@users.noreply.github.com>
1 parent 036a60e commit 054385c

File tree

3 files changed

+239
-0
lines changed

3 files changed

+239
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 5.2.9 on 2025-12-09 17:33
2+
3+
import django.db.models.deletion
4+
import django_extensions.db.fields
5+
from django.db import migrations, models
6+
from django_safemigrate import Safe
7+
8+
9+
class Migration(migrations.Migration):
10+
safe = Safe.before_deploy()
11+
12+
dependencies = [
13+
('builds', '0065_task_executed_at'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='BuildConfig',
19+
fields=[
20+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
22+
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
23+
('data', models.JSONField(help_text='The rendered YAML configuration used in the build', unique=True, verbose_name='Configuration data')),
24+
],
25+
options={
26+
'verbose_name': 'Build configuration',
27+
'verbose_name_plural': 'Build configurations',
28+
},
29+
),
30+
migrations.AddField(
31+
model_name='build',
32+
name='readthedocs_yaml_data',
33+
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'),
34+
),
35+
]

readthedocs/builds/models.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,28 @@ def save(self, *args, **kwargs):
607607
return 0
608608

609609

610+
class BuildConfig(TimeStampedModel):
611+
"""
612+
Build configuration data.
613+
614+
Stores the rendered YAML configuration used in builds.
615+
The unique constraint ensures we don't duplicate identical configs.
616+
"""
617+
618+
data = models.JSONField(
619+
_("Configuration data"),
620+
unique=True,
621+
help_text=_("The rendered YAML configuration used in the build"),
622+
)
623+
624+
class Meta:
625+
verbose_name = _("Build configuration")
626+
verbose_name_plural = _("Build configurations")
627+
628+
def __str__(self):
629+
return f"BuildConfig {self.pk}"
630+
631+
610632
class Build(models.Model):
611633
"""Build data."""
612634

@@ -698,6 +720,15 @@ class Build(models.Model):
698720
null=True,
699721
blank=True,
700722
)
723+
readthedocs_yaml_data = models.ForeignKey(
724+
"BuildConfig",
725+
verbose_name=_("Build configuration data"),
726+
null=True,
727+
blank=True,
728+
on_delete=models.SET_NULL,
729+
related_name="builds",
730+
help_text=_("The rendered YAML configuration used in the build"),
731+
)
701732
readthedocs_yaml_path = models.CharField(
702733
_("Custom build configuration file path used in this build"),
703734
max_length=1024,
@@ -823,12 +854,24 @@ def save(self, *args, **kwargs): # noqa
823854
824855
If the config is the same, we save the pk of the object
825856
that has the **real** config under the `CONFIG_KEY` key.
857+
858+
Additionally, we create or get a BuildConfig object for the new
859+
readthedocs_yaml_data field to facilitate the migration to the new model.
826860
"""
827861
if self.pk is None or self._config_changed:
828862
previous = self.previous
829863
if previous is not None and self._config and self._config == previous.config:
830864
previous_pk = previous._config.get(self.CONFIG_KEY, previous.pk)
831865
self._config = {self.CONFIG_KEY: previous_pk}
866+
867+
# Populate the new readthedocs_yaml_data field
868+
# We only create a BuildConfig when we have actual config data (not a reference)
869+
if self._config and self.CONFIG_KEY not in self._config:
870+
# Use get_or_create to avoid duplicates and leverage the unique constraint
871+
build_config, created = BuildConfig.objects.get_or_create(
872+
data=self._config
873+
)
874+
self.readthedocs_yaml_data = build_config
832875

833876
if self.version:
834877
self.version_name = self.version.verbose_name
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Tests for BuildConfig model and Build.readthedocs_yaml_data field."""
2+
3+
import django_dynamic_fixture as fixture
4+
import pytest
5+
6+
from readthedocs.builds.models import Build
7+
from readthedocs.builds.models import BuildConfig
8+
from readthedocs.projects.models import Project
9+
10+
11+
@pytest.mark.django_db
12+
class TestBuildConfig:
13+
"""Test BuildConfig model functionality."""
14+
15+
def test_buildconfig_creation(self):
16+
"""Test that BuildConfig can be created with data."""
17+
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
18+
build_config = BuildConfig.objects.create(data=config_data)
19+
20+
assert build_config.pk is not None
21+
assert build_config.data == config_data
22+
23+
def test_buildconfig_unique_constraint(self):
24+
"""Test that BuildConfig enforces unique constraint on data."""
25+
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
26+
27+
# Create first BuildConfig
28+
BuildConfig.objects.create(data=config_data)
29+
30+
# Try to create another with the same data - should raise IntegrityError
31+
from django.db import IntegrityError
32+
with pytest.raises(IntegrityError):
33+
BuildConfig.objects.create(data=config_data)
34+
35+
def test_buildconfig_get_or_create(self):
36+
"""Test that get_or_create works correctly for deduplication."""
37+
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
38+
39+
# First call creates
40+
build_config1, created1 = BuildConfig.objects.get_or_create(data=config_data)
41+
assert created1 is True
42+
43+
# Second call gets existing
44+
build_config2, created2 = BuildConfig.objects.get_or_create(data=config_data)
45+
assert created2 is False
46+
assert build_config1.pk == build_config2.pk
47+
48+
49+
@pytest.mark.django_db
50+
class TestBuildReadthedocsYamlData:
51+
"""Test Build.readthedocs_yaml_data field and integration."""
52+
53+
def test_build_saves_with_config_creates_buildconfig(self):
54+
"""Test that saving a Build with config creates BuildConfig."""
55+
project = fixture.get(Project)
56+
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
57+
58+
build = fixture.get(Build, project=project)
59+
build.config = config_data
60+
build.save()
61+
62+
# Check that both old and new fields are populated
63+
assert build._config == config_data
64+
assert build.readthedocs_yaml_data is not None
65+
assert build.readthedocs_yaml_data.data == config_data
66+
67+
def test_build_with_same_config_reuses_buildconfig(self):
68+
"""Test that builds with same config reuse the same BuildConfig."""
69+
project = fixture.get(Project)
70+
config_data = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
71+
72+
# Create first build
73+
build1 = fixture.get(Build, project=project)
74+
build1.config = config_data
75+
build1.save()
76+
77+
# Create second build with same config
78+
build2 = fixture.get(Build, project=project)
79+
build2.config = config_data
80+
build2.save()
81+
82+
# Both should reference the same BuildConfig
83+
assert build1.readthedocs_yaml_data.pk == build2.readthedocs_yaml_data.pk
84+
assert BuildConfig.objects.count() == 1
85+
86+
def test_build_with_different_config_creates_new_buildconfig(self):
87+
"""Test that builds with different configs create separate BuildConfigs."""
88+
project = fixture.get(Project)
89+
config_data1 = {"build": {"os": "ubuntu-22.04"}, "python": {"version": "3.11"}}
90+
config_data2 = {"build": {"os": "ubuntu-20.04"}, "python": {"version": "3.10"}}
91+
92+
# Create first build
93+
build1 = fixture.get(Build, project=project)
94+
build1.config = config_data1
95+
build1.save()
96+
97+
# Create second build with different config
98+
build2 = fixture.get(Build, project=project)
99+
build2.config = config_data2
100+
build2.save()
101+
102+
# Should have different BuildConfigs
103+
assert build1.readthedocs_yaml_data.pk != build2.readthedocs_yaml_data.pk
104+
assert BuildConfig.objects.count() == 2
105+
106+
def test_build_without_config_does_not_create_buildconfig(self):
107+
"""Test that a Build without config doesn't create a BuildConfig."""
108+
project = fixture.get(Project)
109+
build = fixture.get(Build, project=project)
110+
111+
# Build has no config set
112+
build.save()
113+
114+
assert build._config is None
115+
assert build.readthedocs_yaml_data is None
116+
assert BuildConfig.objects.count() == 0
117+
118+
def test_build_with_config_reference_uses_same_buildconfig(self):
119+
"""Test that a Build with config reference (old style) doesn't create a new BuildConfig."""
120+
from readthedocs.builds.models import Version
121+
122+
project = fixture.get(Project)
123+
version = fixture.get(Version, project=project)
124+
config_data = {"build": {"os": "ubuntu-22.04"}}
125+
126+
# Create a build with actual config
127+
build1 = fixture.get(Build, project=project, version=version)
128+
build1.config = config_data
129+
build1.save()
130+
131+
# Create a build with same config on the same version
132+
# (which will use the reference style in _config)
133+
build2 = fixture.get(Build, project=project, version=version)
134+
build2.config = config_data
135+
build2.save()
136+
137+
# build2 should have a reference in _config, not actual data
138+
assert Build.CONFIG_KEY in build2._config
139+
# build1 should have created a BuildConfig
140+
assert build1.readthedocs_yaml_data is not None
141+
# build2 should not create a new BuildConfig since it uses reference style
142+
assert build2.readthedocs_yaml_data is None
143+
# There should only be one BuildConfig created
144+
assert BuildConfig.objects.count() == 1
145+
146+
def test_buildconfig_related_builds(self):
147+
"""Test that BuildConfig.builds related manager works."""
148+
project = fixture.get(Project)
149+
config_data = {"build": {"os": "ubuntu-22.04"}}
150+
151+
# Create BuildConfig
152+
build_config = BuildConfig.objects.create(data=config_data)
153+
154+
# Create builds that reference it
155+
build1 = fixture.get(Build, project=project, readthedocs_yaml_data=build_config)
156+
build2 = fixture.get(Build, project=project, readthedocs_yaml_data=build_config)
157+
158+
# Check related manager
159+
assert build_config.builds.count() == 2
160+
assert build1 in build_config.builds.all()
161+
assert build2 in build_config.builds.all()

0 commit comments

Comments
 (0)