Skip to content

Commit 0c75f56

Browse files
feat: move containers into a new applet
1 parent 92670d4 commit 0c75f56

24 files changed

Lines changed: 1914 additions & 1820 deletions

File tree

.importlinter

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ layers=
4848
# Problems, Videos, and blocks of HTML text. This is also the type we would
4949
# associate with a single "leaf" XBlock–one that is not a container type and
5050
# has no child elements.
51-
openedx_content.applets.components
51+
# The "containers" app is built on top of publishing, and is a peer to
52+
# "components" but they do not depend on each other.
53+
openedx_content.applets.components | openedx_content.applets.publishing
5254

5355
# The "media" applet stores the simplest pieces of binary and text data,
5456
# without versioning information. These belong to a single Learning Package.

src/openedx_content/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# pylint: disable=wildcard-import
1313
from .applets.backup_restore.api import *
1414
from .applets.collections.api import *
15+
from .applets.containers.api import *
1516
from .applets.components.api import *
1617
from .applets.media.api import *
1718
from .applets.publishing.api import *

src/openedx_content/applets/backup_restore/zipper.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ..collections import api as collections_api
3333
from ..components import api as components_api
3434
from ..media import api as media_api
35+
from ..containers import api as containers_api
3536
from ..publishing import api as publishing_api
3637
from ..units.models import Unit
3738
from ..subsections.models import Subsection
@@ -809,15 +810,15 @@ def _save_container(
809810
learning_package,
810811
containers,
811812
*,
812-
container_type: publishing_api.ContainerType,
813+
container_type: containers_api.ContainerType,
813814
container_map: dict,
814815
children_map: dict,
815816
):
816817
"""Internal logic for _save_units, _save_subsections, and _save_sections"""
817818
type_code = container_type.type_code # e.g. "unit"
818819
for data in containers.get(type_code, []):
819820
entity_key = data.get("key")
820-
container = publishing_api.create_container(
821+
container = containers_api.create_container(
821822
learning_package.id,
822823
**data, # should this be allowed to override any of the following fields?
823824
created_by=self.user_id,
@@ -831,7 +832,7 @@ def _save_container(
831832
self.all_published_entities_versions.add(
832833
(entity_key, valid_published.get('version_num'))
833834
) # Track published version
834-
publishing_api.create_next_container_version(
835+
containers_api.create_next_container_version(
835836
container_map[entity_key],
836837
**valid_published, # should this be allowed to override any of the following fields?
837838
force_version_num=valid_published.pop("version_num", None),
@@ -889,7 +890,7 @@ def _save_draft_versions(self, components, containers, component_static_files):
889890
)
890891

891892
def _process_draft_containers(
892-
container_type: publishing_api.ContainerType,
893+
container_type: containers_api.ContainerType,
893894
container_map: dict,
894895
children_map: dict,
895896
):
@@ -900,7 +901,7 @@ def _process_draft_containers(
900901
continue
901902
children = self._resolve_children(valid_draft, children_map)
902903
del valid_draft["version_num"]
903-
publishing_api.create_next_container_version(
904+
containers_api.create_next_container_version(
904905
container_map[entity_key],
905906
**valid_draft, # should this be allowed to override any of the following fields?
906907
entities=children,

src/openedx_content/applets/collections/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
from django.core.exceptions import ValidationError
99
from django.db.models import QuerySet
1010

11+
from ..containers.models import Container
1112
from ..publishing import api as publishing_api
12-
from ..publishing.models import Container, PublishableEntity
13+
from ..publishing.models import PublishableEntity
1314
from .models import Collection, CollectionPublishableEntity
1415

1516
# The public API that will be re-exported by openedx_content.api

src/openedx_content/applets/containers/__init__.py

Whitespace-only changes.
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""
2+
Django admin for containers models
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import functools
8+
9+
from django.contrib import admin
10+
from django.utils.html import format_html
11+
from django.utils.safestring import SafeText
12+
13+
from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link, one_to_one_related_model_html
14+
15+
from .models import (
16+
Container,
17+
ContainerVersion,
18+
EntityList,
19+
EntityListRow,
20+
)
21+
22+
23+
def _entity_list_detail_link(el: EntityList) -> SafeText:
24+
"""
25+
A link to the detail page for an EntityList which includes its PK and length.
26+
"""
27+
num_rows = el.entitylistrow_set.count()
28+
rows_noun = "row" if num_rows == 1 else "rows"
29+
return model_detail_link(el, f"EntityList #{el.pk} with {num_rows} {rows_noun}")
30+
31+
32+
class ContainerVersionInlineForContainer(admin.TabularInline):
33+
"""
34+
Inline admin view of ContainerVersions in a given Container
35+
"""
36+
37+
model = ContainerVersion
38+
ordering = ["-publishable_entity_version__version_num"]
39+
fields = [
40+
"pk",
41+
"version_num",
42+
"title",
43+
"children",
44+
"created",
45+
"created_by",
46+
]
47+
readonly_fields = fields # type: ignore[assignment]
48+
extra = 0
49+
50+
def get_queryset(self, request):
51+
return super().get_queryset(request).select_related("publishable_entity_version")
52+
53+
def children(self, obj: ContainerVersion):
54+
return _entity_list_detail_link(obj.entity_list)
55+
56+
57+
@admin.register(Container)
58+
class ContainerAdmin(ReadOnlyModelAdmin):
59+
"""
60+
Django admin configuration for Container
61+
"""
62+
63+
list_display = ("key", "created", "draft", "published", "see_also")
64+
fields = [
65+
"pk",
66+
"publishable_entity",
67+
"learning_package",
68+
"draft",
69+
"published",
70+
"created",
71+
"created_by",
72+
"see_also",
73+
"most_recent_parent_entity_list",
74+
]
75+
readonly_fields = fields # type: ignore[assignment]
76+
search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
77+
inlines = [ContainerVersionInlineForContainer]
78+
79+
def learning_package(self, obj: Container) -> SafeText:
80+
return model_detail_link(
81+
obj.publishable_entity.learning_package,
82+
obj.publishable_entity.learning_package.key,
83+
)
84+
85+
def get_queryset(self, request):
86+
return (
87+
super()
88+
.get_queryset(request)
89+
.select_related(
90+
"publishable_entity",
91+
"publishable_entity__learning_package",
92+
"publishable_entity__published__version",
93+
"publishable_entity__draft__version",
94+
)
95+
)
96+
97+
def draft(self, obj: Container) -> str:
98+
"""
99+
Link to this Container's draft ContainerVersion
100+
"""
101+
if draft := obj.versioning.draft:
102+
return format_html(
103+
'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list)
104+
)
105+
return "-"
106+
107+
def published(self, obj: Container) -> str:
108+
"""
109+
Link to this Container's published ContainerVersion
110+
"""
111+
if published := obj.versioning.published:
112+
return format_html(
113+
'Version {} "{}" ({})',
114+
published.version_num,
115+
published.title,
116+
_entity_list_detail_link(published.entity_list),
117+
)
118+
return "-"
119+
120+
def see_also(self, obj: Container):
121+
return one_to_one_related_model_html(obj)
122+
123+
def most_recent_parent_entity_list(self, obj: Container) -> str:
124+
if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first():
125+
return _entity_list_detail_link(latest_row.entity_list)
126+
return "-"
127+
128+
129+
class ContainerVersionInlineForEntityList(admin.TabularInline):
130+
"""
131+
Inline admin view of ContainerVersions which use a given EntityList
132+
"""
133+
134+
model = ContainerVersion
135+
verbose_name = "Container Version that references this Entity List"
136+
verbose_name_plural = "Container Versions that reference this Entity List"
137+
ordering = ["-pk"] # Newest first
138+
fields = [
139+
"pk",
140+
"version_num",
141+
"container_key",
142+
"title",
143+
"created",
144+
"created_by",
145+
]
146+
readonly_fields = fields # type: ignore[assignment]
147+
extra = 0
148+
149+
def get_queryset(self, request):
150+
return (
151+
super()
152+
.get_queryset(request)
153+
.select_related(
154+
"container",
155+
"container__publishable_entity",
156+
"publishable_entity_version",
157+
)
158+
)
159+
160+
def container_key(self, obj: ContainerVersion) -> SafeText:
161+
return model_detail_link(obj.container, obj.container.key)
162+
163+
164+
class EntityListRowInline(admin.TabularInline):
165+
"""
166+
Table of entity rows in the entitylist admin
167+
"""
168+
169+
model = EntityListRow
170+
readonly_fields = [
171+
"order_num",
172+
"pinned_version_num",
173+
"entity_models",
174+
"container_models",
175+
"container_children",
176+
]
177+
fields = readonly_fields # type: ignore[assignment]
178+
179+
def get_queryset(self, request):
180+
return (
181+
super()
182+
.get_queryset(request)
183+
.select_related(
184+
"entity",
185+
"entity_version",
186+
)
187+
)
188+
189+
def pinned_version_num(self, obj: EntityListRow):
190+
return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)"
191+
192+
def entity_models(self, obj: EntityListRow):
193+
return format_html(
194+
"{}<ul>{}</ul>",
195+
model_detail_link(obj.entity, obj.entity.key),
196+
one_to_one_related_model_html(obj.entity),
197+
)
198+
199+
def container_models(self, obj: EntityListRow) -> SafeText:
200+
if not hasattr(obj.entity, "container"):
201+
return SafeText("(Not a Container)")
202+
return format_html(
203+
"{}<ul>{}</ul>",
204+
model_detail_link(obj.entity.container, str(obj.entity.container)),
205+
one_to_one_related_model_html(obj.entity.container),
206+
)
207+
208+
def container_children(self, obj: EntityListRow) -> SafeText:
209+
"""
210+
If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing.
211+
212+
When determining which ContainerVersion to grab the EntityList from, prefer the pinned
213+
version if there is one; otherwise use the Draft version.
214+
"""
215+
if not hasattr(obj.entity, "container"):
216+
return SafeText("(Not a Container)")
217+
child_container_version: ContainerVersion = (
218+
obj.entity_version.containerversion if obj.entity_version else obj.entity.container.versioning.draft
219+
)
220+
return _entity_list_detail_link(child_container_version.entity_list)
221+
222+
223+
@admin.register(EntityList)
224+
class EntityListAdmin(ReadOnlyModelAdmin):
225+
"""
226+
Django admin configuration for EntityList
227+
"""
228+
229+
list_display = [
230+
"entity_list",
231+
"row_count",
232+
"recent_container_version_num",
233+
"recent_container",
234+
"recent_container_package",
235+
]
236+
inlines = [ContainerVersionInlineForEntityList, EntityListRowInline]
237+
238+
def entity_list(self, obj: EntityList) -> SafeText:
239+
return model_detail_link(obj, f"EntityList #{obj.pk}")
240+
241+
def row_count(self, obj: EntityList) -> int:
242+
return obj.entitylistrow_set.count()
243+
244+
def recent_container_version_num(self, obj: EntityList) -> str:
245+
"""
246+
Number of the newest ContainerVersion that references this EntityList
247+
"""
248+
if latest := _latest_container_version(obj):
249+
return f"Version {latest.version_num}"
250+
else:
251+
return "-"
252+
253+
def recent_container(self, obj: EntityList) -> SafeText | None:
254+
"""
255+
Link to the Container of the newest ContainerVersion that references this EntityList
256+
"""
257+
if latest := _latest_container_version(obj):
258+
return format_html("of: {}", model_detail_link(latest.container, latest.container.key))
259+
else:
260+
return None
261+
262+
def recent_container_package(self, obj: EntityList) -> SafeText | None:
263+
"""
264+
Link to the LearningPackage of the newest ContainerVersion that references this EntityList
265+
"""
266+
if latest := _latest_container_version(obj):
267+
return format_html(
268+
"in: {}",
269+
model_detail_link(
270+
latest.container.publishable_entity.learning_package,
271+
latest.container.publishable_entity.learning_package.key,
272+
),
273+
)
274+
else:
275+
return None
276+
277+
# We'd like it to appear as if these three columns are just a single
278+
# nicely-formatted column, so only give the left one a description.
279+
recent_container_version_num.short_description = ( # type: ignore[attr-defined]
280+
"Most recent container version using this entity list"
281+
)
282+
recent_container.short_description = "" # type: ignore[attr-defined]
283+
recent_container_package.short_description = "" # type: ignore[attr-defined]
284+
285+
286+
@functools.cache
287+
def _latest_container_version(obj: EntityList) -> ContainerVersion | None:
288+
"""
289+
Any given EntityList can be used by multiple ContainerVersion (which may even
290+
span multiple Containers). We only have space here to show one ContainerVersion
291+
easily, so let's show the one that's most likely to be interesting to the Django
292+
admin user: the most-recently-created one.
293+
"""
294+
return obj.container_versions.order_by("-pk").first()

0 commit comments

Comments
 (0)