Skip to content

Commit c2f57df

Browse files
feat: Subsections in Learning Core (#301)
Subsections are containers whose children are Units. * Created new subsections models and API as part of the subsections app * Implemented container behavior where publishing a subsection automatically publishes its child units * Added comprehensive functionality to manage the subsection lifecycle: * Creation of subsections and subsection versions * Ability to pin specific unit versions or use the latest versions * Retrieval of units contained within subsections (both draft and published) * Added high-level APIs for subsection management: * `get_units_in_subsection` to retrieve units in draft or published subsections * `get_units_in_published_subsection_as_of` to access historical states of subsections * Various utility functions for subsection creation and version management
1 parent c9ae86d commit c2f57df

File tree

15 files changed

+1539
-3
lines changed

15 files changed

+1539
-3
lines changed

.annotation_safe_list.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ oel_tagging.TagImportTask:
6565
".. no_pii:": "This model has no PII"
6666
oel_tagging.Taxonomy:
6767
".. no_pii:": "This model has no PII"
68+
oel_subsections.Subsection:
69+
".. no_pii:": "This model has no PII"
70+
oel_subsections.SubsectionVersion:
71+
".. no_pii:": "This model has no PII"
6872
oel_units.Unit:
6973
".. no_pii:": "This model has no PII"
7074
oel_units.UnitVersion:

openedx_learning/api/authoring.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ..apps.authoring.components.api import *
1414
from ..apps.authoring.contents.api import *
1515
from ..apps.authoring.publishing.api import *
16+
from ..apps.authoring.subsections.api import *
1617
from ..apps.authoring.units.api import *
1718

1819
# This was renamed after the authoring API refactoring pushed this and other

openedx_learning/api/authoring_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
from ..apps.authoring.components.models import *
1212
from ..apps.authoring.contents.models import *
1313
from ..apps.authoring.publishing.models import *
14+
from ..apps.authoring.subsections.models import *
1415
from ..apps.authoring.units.models import *

openedx_learning/apps/authoring/publishing/api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1322,7 +1322,10 @@ def contains_unpublished_changes(container_id: int) -> bool:
13221322
return True
13231323

13241324
# We only care about children that are un-pinned, since published changes to pinned children don't matter
1325-
entity_list = container.versioning.draft.entity_list
1325+
entity_list = getattr(container.versioning.draft, "entity_list", None)
1326+
if entity_list is None:
1327+
# This container has been soft-deleted, so it has no children.
1328+
return False
13261329

13271330
# This is a naive and inefficient implementation but should be correct.
13281331
# TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but

openedx_learning/apps/authoring/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Generated by Django 4.2.20 on 2025-04-17 18:22
2-
3-
from django.db import migrations, models
42
import django.db.models.deletion
3+
from django.db import migrations, models
54

65

76
class Migration(migrations.Migration):

openedx_learning/apps/authoring/subsections/__init__.py

Whitespace-only changes.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"""Subsections API.
2+
3+
This module provides functions to manage subsections.
4+
"""
5+
from dataclasses import dataclass
6+
from datetime import datetime
7+
8+
from django.db.transaction import atomic
9+
10+
from openedx_learning.apps.authoring.units.models import Unit, UnitVersion
11+
12+
from ..publishing import api as publishing_api
13+
from .models import Subsection, SubsectionVersion
14+
15+
# 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured
16+
# out our approach to dynamic content (randomized, A/B tests, etc.)
17+
__all__ = [
18+
"create_subsection",
19+
"create_subsection_version",
20+
"create_next_subsection_version",
21+
"create_subsection_and_version",
22+
"get_subsection",
23+
"get_subsection_version",
24+
"get_latest_subsection_version",
25+
"SubsectionListEntry",
26+
"get_units_in_subsection",
27+
"get_units_in_subsection",
28+
"get_units_in_published_subsection_as_of",
29+
]
30+
31+
32+
def create_subsection(
33+
learning_package_id: int,
34+
key: str,
35+
created: datetime,
36+
created_by: int | None,
37+
*,
38+
can_stand_alone: bool = True,
39+
) -> Subsection:
40+
"""
41+
[ 🛑 UNSTABLE ] Create a new subsection.
42+
43+
Args:
44+
learning_package_id: The learning package ID.
45+
key: The key.
46+
created: The creation date.
47+
created_by: The user who created the subsection.
48+
can_stand_alone: Set to False when created as part of containers
49+
"""
50+
return publishing_api.create_container(
51+
learning_package_id,
52+
key,
53+
created,
54+
created_by,
55+
can_stand_alone=can_stand_alone,
56+
container_cls=Subsection,
57+
)
58+
59+
60+
def create_subsection_version(
61+
subsection: Subsection,
62+
version_num: int,
63+
*,
64+
title: str,
65+
entity_rows: list[publishing_api.ContainerEntityRow],
66+
created: datetime,
67+
created_by: int | None = None,
68+
) -> SubsectionVersion:
69+
"""
70+
[ 🛑 UNSTABLE ] Create a new subsection version.
71+
72+
This is a very low-level API, likely only needed for import/export. In
73+
general, you will use `create_subsection_and_version()` and
74+
`create_next_subsection_version()` instead.
75+
76+
Args:
77+
subsection_pk: The subsection ID.
78+
version_num: The version number.
79+
title: The title.
80+
entity_rows: child entities/versions
81+
created: The creation date.
82+
created_by: The user who created the subsection.
83+
"""
84+
return publishing_api.create_container_version(
85+
subsection.pk,
86+
version_num,
87+
title=title,
88+
entity_rows=entity_rows,
89+
created=created,
90+
created_by=created_by,
91+
container_version_cls=SubsectionVersion,
92+
)
93+
94+
95+
def _pub_entities_for_units(
96+
units: list[Unit | UnitVersion] | None,
97+
) -> list[publishing_api.ContainerEntityRow] | None:
98+
"""
99+
Helper method: given a list of Unit | UnitVersion, return the
100+
list of ContainerEntityRows needed for the base container APIs.
101+
102+
UnitVersion is passed when we want to pin a specific version, otherwise
103+
Unit is used for unpinned.
104+
"""
105+
if units is None:
106+
# When these are None, that means don't change the entities in the list.
107+
return None
108+
for u in units:
109+
if not isinstance(u, (Unit, UnitVersion)):
110+
raise TypeError("Subsection units must be either Unit or UnitVersion.")
111+
return [
112+
(
113+
publishing_api.ContainerEntityRow(
114+
entity_pk=u.container.publishable_entity_id,
115+
version_pk=None,
116+
) if isinstance(u, Unit)
117+
else publishing_api.ContainerEntityRow(
118+
entity_pk=u.unit.container.publishable_entity_id,
119+
version_pk=u.container_version.publishable_entity_version_id,
120+
)
121+
)
122+
for u in units
123+
]
124+
125+
126+
def create_next_subsection_version(
127+
subsection: Subsection,
128+
*,
129+
title: str | None = None,
130+
units: list[Unit | UnitVersion] | None = None,
131+
created: datetime,
132+
created_by: int | None = None,
133+
entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
134+
) -> SubsectionVersion:
135+
"""
136+
[ 🛑 UNSTABLE ] Create the next subsection version.
137+
138+
Args:
139+
subsection_pk: The subsection ID.
140+
title: The title. Leave as None to keep the current title.
141+
units: The units, as a list of Units (unpinned) and/or UnitVersions (pinned). Passing None
142+
will leave the existing units unchanged.
143+
created: The creation date.
144+
created_by: The user who created the subsection.
145+
"""
146+
entity_rows = _pub_entities_for_units(units)
147+
subsection_version = publishing_api.create_next_container_version(
148+
subsection.pk,
149+
title=title,
150+
entity_rows=entity_rows,
151+
created=created,
152+
created_by=created_by,
153+
container_version_cls=SubsectionVersion,
154+
entities_action=entities_action,
155+
)
156+
return subsection_version
157+
158+
159+
def create_subsection_and_version(
160+
learning_package_id: int,
161+
key: str,
162+
*,
163+
title: str,
164+
units: list[Unit | UnitVersion] | None = None,
165+
created: datetime,
166+
created_by: int | None = None,
167+
can_stand_alone: bool = True,
168+
) -> tuple[Subsection, SubsectionVersion]:
169+
"""
170+
[ 🛑 UNSTABLE ] Create a new subsection and its version.
171+
172+
Args:
173+
learning_package_id: The learning package ID.
174+
key: The key.
175+
created: The creation date.
176+
created_by: The user who created the subsection.
177+
can_stand_alone: Set to False when created as part of containers
178+
"""
179+
entity_rows = _pub_entities_for_units(units)
180+
with atomic():
181+
subsection = create_subsection(
182+
learning_package_id,
183+
key,
184+
created,
185+
created_by,
186+
can_stand_alone=can_stand_alone,
187+
)
188+
subsection_version = create_subsection_version(
189+
subsection,
190+
1,
191+
title=title,
192+
entity_rows=entity_rows or [],
193+
created=created,
194+
created_by=created_by,
195+
)
196+
return subsection, subsection_version
197+
198+
199+
def get_subsection(subsection_pk: int) -> Subsection:
200+
"""
201+
[ 🛑 UNSTABLE ] Get a subsection.
202+
203+
Args:
204+
subsection_pk: The subsection ID.
205+
"""
206+
return Subsection.objects.get(pk=subsection_pk)
207+
208+
209+
def get_subsection_version(subsection_version_pk: int) -> SubsectionVersion:
210+
"""
211+
[ 🛑 UNSTABLE ] Get a subsection version.
212+
213+
Args:
214+
subsection_version_pk: The subsection version ID.
215+
"""
216+
return SubsectionVersion.objects.get(pk=subsection_version_pk)
217+
218+
219+
def get_latest_subsection_version(subsection_pk: int) -> SubsectionVersion:
220+
"""
221+
[ 🛑 UNSTABLE ] Get the latest subsection version.
222+
223+
Args:
224+
subsection_pk: The subsection ID.
225+
"""
226+
return Subsection.objects.get(pk=subsection_pk).versioning.latest
227+
228+
229+
@dataclass(frozen=True)
230+
class SubsectionListEntry:
231+
"""
232+
[ 🛑 UNSTABLE ]
233+
Data about a single entity in a container, e.g. a unit in a subsection.
234+
"""
235+
unit_version: UnitVersion
236+
pinned: bool = False
237+
238+
@property
239+
def unit(self):
240+
return self.unit_version.unit
241+
242+
243+
def get_units_in_subsection(
244+
subsection: Subsection,
245+
*,
246+
published: bool,
247+
) -> list[SubsectionListEntry]:
248+
"""
249+
[ 🛑 UNSTABLE ]
250+
Get the list of entities and their versions in the draft or published
251+
version of the given Subsection.
252+
253+
Args:
254+
subsection: The Subsection, e.g. returned by `get_subsection()`
255+
published: `True` if we want the published version of the subsection, or
256+
`False` for the draft version.
257+
"""
258+
assert isinstance(subsection, Subsection)
259+
units = []
260+
for entry in publishing_api.get_entities_in_container(subsection, published=published):
261+
# Convert from generic PublishableEntityVersion to UnitVersion:
262+
unit_version = entry.entity_version.containerversion.unitversion
263+
assert isinstance(unit_version, UnitVersion)
264+
units.append(SubsectionListEntry(unit_version=unit_version, pinned=entry.pinned))
265+
return units
266+
267+
268+
def get_units_in_published_subsection_as_of(
269+
subsection: Subsection,
270+
publish_log_id: int,
271+
) -> list[SubsectionListEntry] | None:
272+
"""
273+
[ 🛑 UNSTABLE ]
274+
Get the list of entities and their versions in the published version of the
275+
given container as of the given PublishLog version (which is essentially a
276+
version for the entire learning package).
277+
278+
TODO: This API should be updated to also return the SubsectionVersion so we can
279+
see the subsection title and any other metadata from that point in time.
280+
TODO: accept a publish log UUID, not just int ID?
281+
TODO: move the implementation to be a generic 'containers' implementation
282+
that this subsections function merely wraps.
283+
TODO: optimize, perhaps by having the publishlog store a record of all
284+
ancestors of every modified PublishableEntity in the publish.
285+
"""
286+
assert isinstance(subsection, Subsection)
287+
subsection_pub_entity_version = publishing_api.get_published_version_as_of(
288+
subsection.publishable_entity_id, publish_log_id
289+
)
290+
if subsection_pub_entity_version is None:
291+
return None # This subsection was not published as of the given PublishLog ID.
292+
container_version = subsection_pub_entity_version.containerversion
293+
294+
entity_list = []
295+
rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
296+
for row in rows:
297+
if row.entity_version is not None:
298+
unit_version = row.entity_version.containerversion.unitversion
299+
assert isinstance(unit_version, UnitVersion)
300+
entity_list.append(SubsectionListEntry(unit_version=unit_version, pinned=True))
301+
else:
302+
# Unpinned unit - figure out what its latest published version was.
303+
# This is not optimized. It could be done in one query per subsection rather than one query per unit.
304+
pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
305+
if pub_entity_version:
306+
entity_list.append(
307+
SubsectionListEntry(unit_version=pub_entity_version.containerversion.unitversion, pinned=False)
308+
)
309+
return entity_list
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""
2+
Subsection Django application initialization.
3+
"""
4+
5+
from django.apps import AppConfig
6+
7+
8+
class SubsectionsConfig(AppConfig):
9+
"""
10+
Configuration for the subsections Django application.
11+
"""
12+
13+
name = "openedx_learning.apps.authoring.subsections"
14+
verbose_name = "Learning Core > Authoring > Subsections"
15+
default_auto_field = "django.db.models.BigAutoField"
16+
label = "oel_subsections"
17+
18+
def ready(self):
19+
"""
20+
Register Subsection and SubsectionVersion.
21+
"""
22+
from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel
23+
from .models import Subsection, SubsectionVersion # pylint: disable=import-outside-toplevel
24+
25+
register_content_models(Subsection, SubsectionVersion)

0 commit comments

Comments
 (0)