Skip to content

Commit a3d10bd

Browse files
refactor: use multi-table inheritance
1 parent 9cf955f commit a3d10bd

7 files changed

Lines changed: 68 additions & 170 deletions

File tree

openedx_learning/apps/authoring/publishing/api.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,6 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha
574574
def create_container(
575575
learning_package_id: int,
576576
key: str,
577-
container_type: str,
578577
created: datetime,
579578
created_by: int | None,
580579
) -> Container:
@@ -591,14 +590,12 @@ def create_container(
591590
Returns:
592591
The newly created container.
593592
"""
594-
assert container_type # Shouldn't be empty/none
595593
with atomic():
596594
publishable_entity = create_publishable_entity(
597595
learning_package_id, key, created, created_by
598596
)
599597
container = Container.objects.create(
600598
publishable_entity=publishable_entity,
601-
container_type=container_type,
602599
)
603600
return container
604601

@@ -787,7 +784,6 @@ def create_container_and_version(
787784
learning_package_id: int,
788785
key: str,
789786
*,
790-
container_type: str,
791787
created: datetime,
792788
created_by: int | None,
793789
title: str,
@@ -811,7 +807,7 @@ def create_container_and_version(
811807
The newly created container version.
812808
"""
813809
with atomic():
814-
container = create_container(learning_package_id, key, container_type, created, created_by)
810+
container = create_container(learning_package_id, key, created, created_by)
815811
container_version = create_container_version(
816812
container.publishable_entity.pk,
817813
1,

openedx_learning/apps/authoring/publishing/migrations/0003_containers.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
# Generated by Django 4.2.19 on 2025-03-10 18:03
1+
# Generated by Django 4.2.19 on 2025-03-11 04:10
22

33
import django.db.models.deletion
44
from django.db import migrations, models
55

6-
import openedx_learning.lib.fields
7-
86

97
class Migration(migrations.Migration):
108

@@ -17,7 +15,6 @@ class Migration(migrations.Migration):
1715
name='Container',
1816
fields=[
1917
('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')),
20-
('container_type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500)),
2118
],
2219
options={
2320
'abstract': False,
Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,11 @@
11
"""
22
Container and ContainerVersion models
33
"""
4-
from typing import ClassVar, Self, TypeVar
5-
6-
from django.core.exceptions import ValidationError
74
from django.db import models
85

9-
from openedx_learning.lib.fields import case_sensitive_char_field
10-
from openedx_learning.lib.managers import WithRelationsManager
11-
126
from ..model_mixins.publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin
137
from .entity_list import EntityList
148

15-
M = TypeVar('M', bound="Container")
16-
17-
18-
class ContainerManager(WithRelationsManager[M]):
19-
"""
20-
A custom manager used for Container and its subclasses.
21-
"""
22-
def __init__(self):
23-
"""
24-
Initialize the manager for Container / a Container subclass
25-
"""
26-
super().__init__(
27-
# Select these related entities by default:
28-
"publishable_entity",
29-
"publishable_entity__published",
30-
"publishable_entity__draft",
31-
)
32-
33-
def get_queryset(self) -> models.QuerySet:
34-
"""
35-
Apply filter() and select_related() to all querysets.
36-
"""
37-
qs = super().get_queryset()
38-
if self.model.CONTAINER_TYPE:
39-
qs = qs.filter(container_type=self.model.CONTAINER_TYPE)
40-
return qs
41-
42-
def create(self, **kwargs) -> M:
43-
"""
44-
Apply the values from our filter when creating new instances.
45-
"""
46-
if self.model.CONTAINER_TYPE:
47-
# Don't allow creating via a subclass, like Unit.objects.create().
48-
# Instead use create_container() which calls Container.objects.create(..., container_type=...)
49-
raise ValidationError("Container instances should only be created via APIs like create_container()")
50-
return super().create(**kwargs)
51-
529

5310
class Container(PublishableEntityMixin):
5411
"""
@@ -65,40 +22,6 @@ class Container(PublishableEntityMixin):
6522
PublishLog and Containers that were affected in a publish because their
6623
child elements were published.
6724
"""
68-
# Subclasses (django proxy classes) should override this
69-
CONTAINER_TYPE = ""
70-
71-
objects: ClassVar[ContainerManager[Self]] = ContainerManager() # type: ignore[assignment]
72-
73-
container_type = case_sensitive_char_field(max_length=500)
74-
75-
def save(self, *args, **kwargs):
76-
if not self.container_type:
77-
raise ValidationError("Container instances should only be created via APIs like create_container()")
78-
return super().save(*args, **kwargs)
79-
80-
def clean(self):
81-
"""
82-
Validate this container subclass
83-
"""
84-
if self.container_type and self.CONTAINER_TYPE:
85-
if self.container_type != self.CONTAINER_TYPE:
86-
raise ValidationError("container type field mismatch with model.")
87-
super().clean()
88-
89-
@classmethod
90-
def cast_from(cls, instance: "Container") -> Self:
91-
"""
92-
Create a new copy of a Container object, with a different subclass
93-
"""
94-
assert instance.container_type == cls.CONTAINER_TYPE
95-
new_instance = cls(
96-
pk=instance.pk,
97-
container_type=instance.container_type,
98-
)
99-
# Copy Django's internal cache of related objects
100-
new_instance._state.fields_cache.update(instance._state.fields_cache) # pylint: disable=protected-access
101-
return new_instance
10225

10326

10427
class ContainerVersion(PublishableEntityVersionMixin):
@@ -135,17 +58,3 @@ class ContainerVersion(PublishableEntityVersionMixin):
13558
null=False,
13659
related_name="container_versions",
13760
)
138-
139-
@classmethod
140-
def cast_from(cls, instance: "ContainerVersion") -> Self:
141-
"""
142-
Create a new copy of a Container object, with a different subclass
143-
"""
144-
new_instance = cls(
145-
pk=instance.pk,
146-
container_id=instance.container_id,
147-
entity_list_id=instance.entity_list_id,
148-
)
149-
# Copy Django's internal cache of related objects
150-
new_instance._state.fields_cache.update(instance._state.fields_cache) # pylint: disable=protected-access
151-
return new_instance

openedx_learning/apps/authoring/units/api.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,14 @@ def create_unit(
4141
created: The creation date.
4242
created_by: The user who created the unit.
4343
"""
44-
container = publishing_api.create_container(
45-
learning_package_id,
46-
key,
47-
Unit.CONTAINER_TYPE,
48-
created,
49-
created_by,
50-
)
51-
return Unit.cast_from(container)
52-
# Or, more robust but using additional queries:
53-
# return Unit.objects.select_related(None).get(pk=container.pk)
44+
with atomic():
45+
container = publishing_api.create_container(
46+
learning_package_id,
47+
key,
48+
created,
49+
created_by,
50+
)
51+
return Unit.objects.create(container_id=container.pk)
5452

5553

5654
def create_unit_version(
@@ -79,17 +77,20 @@ def create_unit_version(
7977
created: The creation date.
8078
created_by: The user who created the unit.
8179
"""
82-
container_version = publishing_api.create_container_version(
83-
unit.pk,
84-
version_num,
85-
title=title,
86-
publishable_entities_pks=publishable_entities_pks,
87-
entity_version_pks=entity_version_pks,
88-
created=created,
89-
created_by=created_by,
90-
)
91-
# return UnitVersion.objects.get(pk=container_version.pk)
92-
return UnitVersion.cast_from(container_version)
80+
with atomic():
81+
container_version = publishing_api.create_container_version(
82+
unit.pk,
83+
version_num,
84+
title=title,
85+
publishable_entities_pks=publishable_entities_pks,
86+
entity_version_pks=entity_version_pks,
87+
created=created,
88+
created_by=created_by,
89+
)
90+
# unit_version = UnitVersion(container_version=container_version) # 🛑 This has a bug :/
91+
unit_version = UnitVersion(container_version_id=container_version.pk)
92+
unit_version.save_base(raw=True) # 🛑 private method, but calling just .save() or .create() causes a bug :/
93+
return unit_version
9394

9495

9596
def _pub_entities_for_components(
@@ -140,15 +141,19 @@ def create_next_unit_version(
140141
created_by: The user who created the unit.
141142
"""
142143
publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components)
143-
container_version = publishing_api.create_next_container_version(
144-
unit.pk,
145-
title=title,
146-
publishable_entities_pks=publishable_entities_pks,
147-
entity_version_pks=entity_version_pks,
148-
created=created,
149-
created_by=created_by,
150-
)
151-
return UnitVersion.cast_from(container_version)
144+
with atomic():
145+
container_version = publishing_api.create_next_container_version(
146+
unit.pk,
147+
title=title,
148+
publishable_entities_pks=publishable_entities_pks,
149+
entity_version_pks=entity_version_pks,
150+
created=created,
151+
created_by=created_by,
152+
)
153+
# unit_version = UnitVersion(container_version=container_version) # 🛑 This has a bug :/
154+
unit_version = UnitVersion(container_version_id=container_version.pk)
155+
unit_version.save_base(raw=True) # 🛑 private method, but calling just .save() or .create() causes a bug :/
156+
return unit_version
152157

153158

154159
def create_unit_and_version(

openedx_learning/apps/authoring/units/migrations/0001_initial.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# Generated by Django 4.2.19 on 2025-03-10 18:18
1+
# Generated by Django 4.2.19 on 2025-03-11 04:31
22

3-
from django.db import migrations
3+
from django.db import migrations, models
4+
import django.db.models.deletion
45

56

67
class Migration(migrations.Migration):
@@ -15,22 +16,20 @@ class Migration(migrations.Migration):
1516
migrations.CreateModel(
1617
name='Unit',
1718
fields=[
19+
('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')),
1820
],
1921
options={
20-
'proxy': True,
21-
'indexes': [],
22-
'constraints': [],
22+
'abstract': False,
2323
},
2424
bases=('oel_publishing.container',),
2525
),
2626
migrations.CreateModel(
2727
name='UnitVersion',
2828
fields=[
29+
('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.containerversion')),
2930
],
3031
options={
31-
'proxy': True,
32-
'indexes': [],
33-
'constraints': [],
32+
'abstract': False,
3433
},
3534
bases=('oel_publishing.containerversion',),
3635
),
Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Models that implement units
33
"""
4+
from django.db import models
45

56
from ..publishing.models import Container, ContainerVersion
67

@@ -14,20 +15,25 @@ class Unit(Container):
1415
"""
1516
A Unit is Container, which is a PublishableEntity.
1617
"""
17-
CONTAINER_TYPE = "unit"
18+
container = models.OneToOneField(
19+
Container,
20+
on_delete=models.CASCADE,
21+
parent_link=True,
22+
primary_key=True,
23+
)
1824

19-
class Meta:
20-
proxy = True
25+
@property
26+
def versioning(self):
27+
return self.container.versioning
2128

2229

2330
class UnitVersion(ContainerVersion):
2431
"""
2532
A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion.
2633
"""
27-
28-
@property
29-
def unit(self):
30-
return Unit.objects.get(pk=self.container_id)
31-
32-
class Meta:
33-
proxy = True
34+
container_version = models.OneToOneField(
35+
ContainerVersion,
36+
on_delete=models.CASCADE,
37+
parent_link=True,
38+
primary_key=True,
39+
)

tests/openedx_learning/apps/authoring/units/test_api.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -90,26 +90,6 @@ def test_get_unit(self):
9090
with self.assertNumQueries(0):
9191
assert result.versioning.has_unpublished_changes
9292

93-
def test_get_unit_non_unit(self):
94-
"""
95-
Test that get_unit() cannot retrieve other container types
96-
"""
97-
not_unit, _version = authoring_api.create_container_and_version(
98-
self.learning_package.id,
99-
key="foobar",
100-
container_type="NOT a unit", # <-- the important part
101-
created=self.now,
102-
created_by=None,
103-
title="Testing",
104-
publishable_entities_pks=[],
105-
entity_version_pks=[]
106-
)
107-
# This generic method will work:
108-
authoring_api.get_container(not_unit.pk)
109-
# But the unit method will not:
110-
with self.assertRaises(authoring_models.Unit.DoesNotExist):
111-
authoring_api.get_unit(not_unit.pk)
112-
11393
def test_get_container(self):
11494
"""
11595
Test get_container()
@@ -894,7 +874,10 @@ def test_units_containing(self):
894874
# No need to publish anything as the get_containers_with_entity() API only considers drafts (for now).
895875

896876
with self.assertNumQueries(1):
897-
result = list(authoring_api.get_containers_with_entity(self.component_1.pk))
877+
result = [
878+
c.unit for c in
879+
authoring_api.get_containers_with_entity(self.component_1.pk).select_related("unit")
880+
]
898881
assert result == [
899882
unit1_1pinned,
900883
unit2_1pinned_v2,
@@ -905,7 +888,10 @@ def test_units_containing(self):
905888
# about pinned uses anyways (they would be unaffected by a delete).
906889

907890
with self.assertNumQueries(1):
908-
result2 = list(authoring_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True))
891+
result2 = [
892+
c.unit for c in
893+
authoring_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True).select_related("unit")
894+
]
909895
assert result2 == [unit4_unpinned]
910896

911897
# Tests TODO:

0 commit comments

Comments
 (0)