Skip to content

Commit 875bf3e

Browse files
Merge pull request #1 from raccoongang/hantkovskyi/axm-1879/implement-cl-subsection
feat: [AXM-1879] implement contentlibrary subsections
2 parents 251355c + 91819c7 commit 875bf3e

13 files changed

Lines changed: 1503 additions & 1 deletion

File tree

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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,11 @@ def contains_unpublished_changes(container_id: int) -> bool:
10261026
return True
10271027

10281028
# We only care about children that are un-pinned, since published changes to pinned children don't matter
1029-
entity_list = container.versioning.draft.entity_list
1029+
entity_list = getattr(container.versioning.draft, "entity_list", None)
1030+
# ?FOR REVIEW: Is this correct?
1031+
if entity_list is None:
1032+
# This container has not been published yet, or has been deleted.
1033+
return False
10301034

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

openedx_learning/apps/authoring/subsections/__init__.py

Whitespace-only changes.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
publishable_entities_pks: list[int],
66+
entity_version_pks: list[int | None],
67+
created: datetime,
68+
created_by: int | None = None,
69+
) -> SubsectionVersion:
70+
"""
71+
[ 🛑 UNSTABLE ] Create a new subsection version.
72+
73+
This is a very low-level API, likely only needed for import/export. In
74+
general, you will use `create_subsection_and_version()` and
75+
`create_next_subsection_version()` instead.
76+
77+
Args:
78+
subsection_pk: The subsection ID.
79+
version_num: The version number.
80+
title: The title.
81+
publishable_entities_pk: The publishable entities.
82+
entity: The entity.
83+
created: The creation date.
84+
created_by: The user who created the subsection.
85+
"""
86+
return publishing_api.create_container_version(
87+
subsection.pk,
88+
version_num,
89+
title=title,
90+
publishable_entities_pks=publishable_entities_pks,
91+
entity_version_pks=entity_version_pks,
92+
created=created,
93+
created_by=created_by,
94+
container_version_cls=SubsectionVersion,
95+
)
96+
97+
98+
def _pub_entities_for_units(
99+
units: list[Unit | UnitVersion] | None,
100+
) -> tuple[list[int], list[int | None]] | tuple[None, None]:
101+
"""
102+
Helper method: given a list of Unit | UnitVersion, return the
103+
lists of publishable_entities_pks and entity_version_pks needed for the
104+
base container APIs.
105+
106+
UnitVersion is passed when we want to pin a specific version, otherwise
107+
Unit is used for unpinned.
108+
"""
109+
if units is None:
110+
# When these are None, that means don't change the entities in the list.
111+
return None, None
112+
for u in units:
113+
if not isinstance(u, (Unit, UnitVersion)):
114+
raise TypeError("Subsection units must be either Unit or UnitVersion.")
115+
publishable_entities_pks = [
116+
(u.publishable_entity_id if isinstance(u, Unit) else u.unit.publishable_entity_id)
117+
for u in units
118+
]
119+
entity_version_pks = [
120+
(uv.pk if isinstance(uv, UnitVersion) else None)
121+
for uv in units
122+
]
123+
return publishable_entities_pks, entity_version_pks
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+
publishable_entities_pks, entity_version_pks = _pub_entities_for_units(units)
147+
subsection_version = publishing_api.create_next_container_version(
148+
subsection.pk,
149+
title=title,
150+
publishable_entities_pks=publishable_entities_pks,
151+
entity_version_pks=entity_version_pks,
152+
created=created,
153+
created_by=created_by,
154+
container_version_cls=SubsectionVersion,
155+
entities_action=entities_action,
156+
)
157+
return subsection_version
158+
159+
160+
def create_subsection_and_version(
161+
learning_package_id: int,
162+
key: str,
163+
*,
164+
title: str,
165+
units: list[Unit | UnitVersion] | None = None,
166+
created: datetime,
167+
created_by: int | None = None,
168+
can_stand_alone: bool = True,
169+
) -> tuple[Subsection, SubsectionVersion]:
170+
"""
171+
[ 🛑 UNSTABLE ] Create a new subsection and its version.
172+
173+
Args:
174+
learning_package_id: The learning package ID.
175+
key: The key.
176+
created: The creation date.
177+
created_by: The user who created the subsection.
178+
can_stand_alone: Set to False when created as part of containers
179+
"""
180+
publishable_entities_pks, entity_version_pks = _pub_entities_for_units(units)
181+
with atomic():
182+
subsection = create_subsection(
183+
learning_package_id,
184+
key,
185+
created,
186+
created_by,
187+
can_stand_alone=can_stand_alone,
188+
)
189+
subsection_version = create_subsection_version(
190+
subsection,
191+
1,
192+
title=title,
193+
publishable_entities_pks=publishable_entities_pks or [],
194+
entity_version_pks=entity_version_pks or [],
195+
created=created,
196+
created_by=created_by,
197+
)
198+
return subsection, subsection_version
199+
200+
201+
def get_subsection(subsection_pk: int) -> Subsection:
202+
"""
203+
[ 🛑 UNSTABLE ] Get a subsection.
204+
205+
Args:
206+
subsection_pk: The subsection ID.
207+
"""
208+
return Subsection.objects.get(pk=subsection_pk)
209+
210+
211+
def get_subsection_version(subsection_version_pk: int) -> SubsectionVersion:
212+
"""
213+
[ 🛑 UNSTABLE ] Get a subsection version.
214+
215+
Args:
216+
subsection_version_pk: The subsection version ID.
217+
"""
218+
return SubsectionVersion.objects.get(pk=subsection_version_pk)
219+
220+
221+
def get_latest_subsection_version(subsection_pk: int) -> SubsectionVersion:
222+
"""
223+
[ 🛑 UNSTABLE ] Get the latest subsection version.
224+
225+
Args:
226+
subsection_pk: The subsection ID.
227+
"""
228+
return Subsection.objects.get(pk=subsection_pk).versioning.latest
229+
230+
231+
@dataclass(frozen=True)
232+
class SubsectionListEntry:
233+
"""
234+
[ 🛑 UNSTABLE ]
235+
Data about a single entity in a container, e.g. a unit in a subsection.
236+
"""
237+
unit_version: UnitVersion
238+
pinned: bool = False
239+
240+
@property
241+
def unit(self):
242+
return self.unit_version.unit
243+
244+
245+
def get_units_in_subsection(
246+
subsection: Subsection,
247+
*,
248+
published: bool,
249+
) -> list[SubsectionListEntry]:
250+
"""
251+
[ 🛑 UNSTABLE ]
252+
Get the list of entities and their versions in the draft or published
253+
version of the given Subsection.
254+
255+
Args:
256+
subsection: The Subsection, e.g. returned by `get_subsection()`
257+
published: `True` if we want the published version of the subsection, or
258+
`False` for the draft version.
259+
"""
260+
assert isinstance(subsection, Subsection)
261+
units = []
262+
for entry in publishing_api.get_entities_in_container(subsection, published=published):
263+
# Convert from generic PublishableEntityVersion to UnitVersion:
264+
unit_version = entry.entity_version.containerversion.unitversion
265+
assert isinstance(unit_version, UnitVersion)
266+
units.append(SubsectionListEntry(unit_version=unit_version, pinned=entry.pinned))
267+
return units
268+
269+
270+
def get_units_in_published_subsection_as_of(
271+
subsection: Subsection,
272+
publish_log_id: int,
273+
) -> list[SubsectionListEntry] | None:
274+
"""
275+
[ 🛑 UNSTABLE ]
276+
Get the list of entities and their versions in the published version of the
277+
given container as of the given PublishLog version (which is essentially a
278+
version for the entire learning package).
279+
280+
TODO: This API should be updated to also return the SubsectionVersion so we can
281+
see the subsection title and any other metadata from that point in time.
282+
TODO: accept a publish log UUID, not just int ID?
283+
TODO: move the implementation to be a generic 'containers' implementation
284+
that this subsections function merely wraps.
285+
TODO: optimize, perhaps by having the publishlog store a record of all
286+
ancestors of every modified PublishableEntity in the publish.
287+
"""
288+
assert isinstance(subsection, Subsection)
289+
subsection_pub_entity_version = publishing_api.get_published_version_as_of(subsection.publishable_entity_id, publish_log_id)
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(SubsectionListEntry(unit_version=pub_entity_version.containerversion.unitversion, pinned=False))
307+
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)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 4.2.19 on 2025-04-09 12:59
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = [
12+
('oel_publishing', '0005_alter_entitylistrow_options'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Subsection',
18+
fields=[
19+
('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')),
20+
],
21+
options={
22+
'abstract': False,
23+
},
24+
bases=('oel_publishing.container',),
25+
),
26+
migrations.CreateModel(
27+
name='SubsectionVersion',
28+
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')),
30+
],
31+
options={
32+
'abstract': False,
33+
},
34+
bases=('oel_publishing.containerversion',),
35+
),
36+
]

openedx_learning/apps/authoring/subsections/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)