Skip to content

Commit 92f8a1b

Browse files
feat: move containers into a new applet
1 parent 034cca9 commit 92f8a1b

25 files changed

Lines changed: 1911 additions & 1828 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.containers
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
@@ -13,6 +13,7 @@
1313
from .applets.backup_restore.api import *
1414
from .applets.collections.api import *
1515
from .applets.components.api import *
16+
from .applets.containers.api import *
1617
from .applets.media.api import *
1718
from .applets.publishing.api import *
1819
from .applets.sections.api import *

src/openedx_content/applets/backup_restore/toml.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
1010

1111
from ..collections.models import Collection
12-
from ..publishing import api as publishing_api
12+
from ..containers import api as containers_api
1313
from ..publishing.models import PublishableEntity, PublishableEntityVersion
1414
from ..publishing.models.learning_package import LearningPackage
1515

@@ -191,7 +191,7 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki
191191
if hasattr(version, 'containerversion'):
192192
# If the version has a container version, add its children
193193
container_table = tomlkit.table()
194-
children = publishing_api.get_container_children_entities_keys(version.containerversion)
194+
children = containers_api.get_container_children_entities_keys(version.containerversion)
195195
container_table.add("children", children)
196196
version_table.add("container", container_table)
197197
return version_table

src/openedx_content/applets/backup_restore/zipper.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from ..collections import api as collections_api
3333
from ..components import api as components_api
34+
from ..containers import api as containers_api
3435
from ..media import api as media_api
3536
from ..publishing import api as publishing_api
3637
from ..sections.models import Section
@@ -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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.db.models import QuerySet
1010

1111
from ..publishing import api as publishing_api
12-
from ..publishing.models import Container, PublishableEntity
12+
from ..publishing.models import PublishableEntity
1313
from .models import Collection, CollectionPublishableEntity
1414

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

src/openedx_content/applets/containers/__init__.py

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

0 commit comments

Comments
 (0)