Skip to content

Commit d0832a8

Browse files
andrii-hantkovskyiAndrii
andauthored
feat: implement content library sections
This is modeled on the existing apps for units and subsections. - Created new sections models and API as part of the sections app - Implemented container behavior where publishing a section automatically publishes its child subsections - Added comprehensive functionality to manage the section lifecycle: - Creation of sections and section versions - Ability to pin specific subsection versions or use the latest versions - Retrieval of subsections contained within sections (both draft and published) - Added high-level APIs for section management: - get_subsections_in_section to retrieve subsections in draft or published sections - get_subsections_in_published_section_as_of to access historical states of sections - Various utility functions for section creation and version management - Bumped openedx_learning to 0.25.0 to account for changes from Subsections in Learning Core #301, which were merged without a version update. --------- Co-authored-by: Andrii <[email protected]>
1 parent 90d0e53 commit d0832a8

File tree

14 files changed

+1491
-1
lines changed

14 files changed

+1491
-1
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_sections.Section:
69+
".. no_pii:": "This model has no PII"
70+
oel_sections.SectionVersion:
71+
".. no_pii:": "This model has no PII"
6872
oel_subsections.Subsection:
6973
".. no_pii:": "This model has no PII"
7074
oel_subsections.SubsectionVersion:

openedx_learning/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Open edX Learning ("Learning Core").
33
"""
44

5-
__version__ = "0.23.1"
5+
__version__ = "0.25.0"

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.sections.api import *
1617
from ..apps.authoring.subsections.api import *
1718
from ..apps.authoring.units.api import *
1819

openedx_learning/api/authoring_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
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.sections.models import *
1415
from ..apps.authoring.subsections.models import *
1516
from ..apps.authoring.units.models import *

openedx_learning/apps/authoring/sections/__init__.py

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

0 commit comments

Comments
 (0)