Skip to content

Commit 61f8fac

Browse files
bradenmacdonaldkdmccormick
authored andcommitted
feat: Browsable Django Admin interface for Containers
1 parent 831b0de commit 61f8fac

8 files changed

Lines changed: 409 additions & 16 deletions

File tree

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.26.0"
5+
__version__ = "0.27.0"

openedx_learning/apps/authoring/publishing/admin.py

Lines changed: 277 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,29 @@
33
"""
44
from __future__ import annotations
55

6+
import functools
7+
68
from django.contrib import admin
79
from django.db.models import Count
10+
from django.utils.html import format_html
11+
from django.utils.safestring import SafeText
812

9-
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html
10-
13+
from openedx_learning.lib.admin_utils import (
14+
ReadOnlyModelAdmin,
15+
model_detail_link,
16+
one_to_one_related_model_html,
17+
)
1118
from .models import (
1219
DraftChangeLog,
1320
DraftChangeLogRecord,
21+
EntityList,
22+
EntityListRow,
1423
LearningPackage,
1524
PublishableEntity,
1625
PublishLog,
1726
PublishLogRecord,
27+
Container,
28+
ContainerVersion,
1829
)
1930
from .models.publish_log import Published
2031

@@ -122,6 +133,12 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
122133
"can_stand_alone",
123134
]
124135

136+
def draft_version(self, entity: PublishableEntity):
137+
return entity.draft.version.version_num if entity.draft.version else None
138+
139+
def published_version(self, entity: PublishableEntity):
140+
return entity.published.version.version_num if entity.published and entity.published.version else None
141+
125142
def get_queryset(self, request):
126143
queryset = super().get_queryset(request)
127144
return queryset.select_related(
@@ -131,16 +148,6 @@ def get_queryset(self, request):
131148
def see_also(self, entity):
132149
return one_to_one_related_model_html(entity)
133150

134-
def draft_version(self, entity):
135-
if entity.draft.version:
136-
return entity.draft.version.version_num
137-
return None
138-
139-
def published_version(self, entity):
140-
if entity.published.version:
141-
return entity.published.version.version_num
142-
return None
143-
144151

145152
@admin.register(Published)
146153
class PublishedAdmin(ReadOnlyModelAdmin):
@@ -246,3 +253,261 @@ def get_queryset(self, request):
246253
queryset = super().get_queryset(request)
247254
return queryset.select_related("learning_package", "changed_by") \
248255
.annotate(num_changes=Count("records"))
256+
257+
258+
def _entity_list_detail_link(el: EntityList) -> SafeText:
259+
"""
260+
A link to the detail page for an EntityList which includes its PK and length.
261+
"""
262+
return model_detail_link(el, f"EntityList #{el.pk} with {el.entitylistrow_set.count()} row(s)")
263+
264+
265+
class ContainerVersionInlineForContainer(admin.TabularInline):
266+
"""
267+
Inline admin view of ContainerVersions in a given Container
268+
"""
269+
model = ContainerVersion
270+
ordering = ["-publishable_entity_version__version_num"]
271+
fields = [
272+
"uuid",
273+
"version_num",
274+
"title",
275+
"children",
276+
"created",
277+
"created_by",
278+
]
279+
readonly_fields = fields # type: ignore[assignment]
280+
extra = 0
281+
282+
def get_queryset(self, request):
283+
return super().get_queryset(request).select_related(
284+
"publishable_entity_version"
285+
)
286+
287+
def children(self, obj: ContainerVersion):
288+
return _entity_list_detail_link(obj.entity_list)
289+
290+
291+
@admin.register(Container)
292+
class ContainerAdmin(ReadOnlyModelAdmin):
293+
"""
294+
Django admin configuration for Container
295+
"""
296+
list_display = ("key", "uuid", "created", "draft", "published", "see_also")
297+
fields = [
298+
"publishable_entity",
299+
"learning_package",
300+
"draft",
301+
"published",
302+
"created",
303+
"created_by",
304+
"see_also",
305+
"most_recent_parent_entity_list",
306+
]
307+
readonly_fields = fields # type: ignore[assignment]
308+
search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
309+
inlines = [ContainerVersionInlineForContainer]
310+
311+
def uuid(self, obj: Container) -> SafeText:
312+
return model_detail_link(obj, obj.uuid)
313+
314+
def learning_package(self, obj: Container) -> SafeText:
315+
return model_detail_link(
316+
obj.publishable_entity.learning_package,
317+
obj.publishable_entity.learning_package.key,
318+
)
319+
320+
def get_queryset(self, request):
321+
return super().get_queryset(request).select_related(
322+
"publishable_entity",
323+
"publishable_entity__learning_package",
324+
"publishable_entity__published__version",
325+
"publishable_entity__draft__version",
326+
)
327+
328+
def draft(self, obj: Container) -> SafeText:
329+
"""
330+
Link to this Container's draft ContainerVersion
331+
"""
332+
if draft := obj.versioning.draft:
333+
return format_html(
334+
"Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list)
335+
)
336+
return SafeText("-")
337+
338+
def published(self, obj: Container) -> SafeText:
339+
"""
340+
Link to this Container's published ContainerVersion
341+
"""
342+
if published := obj.versioning.published:
343+
return format_html(
344+
"Version {} ({})", published.version_num, _entity_list_detail_link(published.entity_list)
345+
)
346+
return SafeText("-")
347+
348+
def see_also(self, obj: Container):
349+
return one_to_one_related_model_html(obj)
350+
351+
def most_recent_parent_entity_list(self, obj: Container) -> SafeText:
352+
if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first():
353+
return _entity_list_detail_link(latest_row.entity_list)
354+
return SafeText("-")
355+
356+
357+
class ContainerVersionInlineForEntityList(admin.TabularInline):
358+
"""
359+
Inline admin view of ContainerVersions which use a given EntityList
360+
"""
361+
model = ContainerVersion
362+
verbose_name = "Container Version that references this Entity List"
363+
verbose_name_plural = "Container Versions that reference this Entity List"
364+
ordering = ["-pk"] # Newest first
365+
fields = [
366+
"uuid",
367+
"version_num",
368+
"container_key",
369+
"title",
370+
"created",
371+
"created_by",
372+
]
373+
readonly_fields = fields # type: ignore[assignment]
374+
extra = 0
375+
376+
def get_queryset(self, request):
377+
return super().get_queryset(request).select_related(
378+
"container",
379+
"container__publishable_entity",
380+
"publishable_entity_version",
381+
)
382+
383+
def container_key(self, obj: ContainerVersion) -> SafeText:
384+
return model_detail_link(obj.container, obj.container.key)
385+
386+
387+
class EntityListRowInline(admin.TabularInline):
388+
"""
389+
Table of entity rows in the entitylist admin
390+
"""
391+
model = EntityListRow
392+
readonly_fields = [
393+
"order_num",
394+
"pinned_version_num",
395+
"entity_models",
396+
"container_models",
397+
"container_children",
398+
]
399+
fields = readonly_fields # type: ignore[assignment]
400+
401+
def get_queryset(self, request):
402+
return super().get_queryset(request).select_related(
403+
"entity",
404+
"entity_version",
405+
)
406+
407+
def pinned_version_num(self, obj: EntityListRow):
408+
return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)"
409+
410+
def entity_models(self, obj: EntityListRow):
411+
return format_html(
412+
"{}<ul>{}</ul>",
413+
model_detail_link(obj.entity, obj.entity.key),
414+
one_to_one_related_model_html(obj.entity),
415+
)
416+
417+
def container_models(self, obj: EntityListRow) -> SafeText:
418+
if not hasattr(obj.entity, "container"):
419+
return SafeText("(Not a Container)")
420+
return format_html(
421+
"{}<ul>{}</ul>",
422+
model_detail_link(obj.entity.container, str(obj.entity.container)),
423+
one_to_one_related_model_html(obj.entity.container),
424+
)
425+
426+
def container_children(self, obj: EntityListRow) -> SafeText:
427+
"""
428+
If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing.
429+
430+
When determining which ContainerVersion to grab the EntityList from, prefer the pinned
431+
version if there is one; otherwise use the Draft version.
432+
"""
433+
if not hasattr(obj.entity, "container"):
434+
return SafeText("(Not a Container)")
435+
child_container_version: ContainerVersion = (
436+
obj.entity_version.containerversion
437+
if obj.entity_version
438+
else obj.entity.container.versioning.draft
439+
)
440+
return _entity_list_detail_link(child_container_version.entity_list)
441+
442+
443+
@admin.register(EntityList)
444+
class EntityListAdmin(ReadOnlyModelAdmin):
445+
"""
446+
Django admin configuration for EntityList
447+
"""
448+
list_display = [
449+
"entity_list",
450+
"row_count",
451+
"recent_container_version_num",
452+
"recent_container",
453+
"recent_container_package"
454+
]
455+
inlines = [ContainerVersionInlineForEntityList, EntityListRowInline]
456+
457+
def entity_list(self, obj: EntityList) -> SafeText:
458+
return model_detail_link(obj, f"EntityList #{obj.pk}")
459+
460+
def row_count(self, obj: EntityList) -> int:
461+
return obj.entitylistrow_set.count()
462+
463+
def recent_container_version_num(self, obj: EntityList) -> str:
464+
"""
465+
Number of the newest ContainerVersion that references this EntityList
466+
"""
467+
if latest := _latest_container_version(obj):
468+
return f"Version {latest.version_num}"
469+
else:
470+
return "-"
471+
472+
def recent_container(self, obj: EntityList) -> SafeText | None:
473+
"""
474+
Link to the Container of the newest ContainerVersion that references this EntityList
475+
"""
476+
if latest := _latest_container_version(obj):
477+
return format_html("of: {}", model_detail_link(latest.container, latest.container.key))
478+
else:
479+
return None
480+
481+
def recent_container_package(self, obj: EntityList) -> SafeText | None:
482+
"""
483+
Link to the LearningPackage of the newest ContainerVersion that references this EntityList
484+
"""
485+
if latest := _latest_container_version(obj):
486+
return format_html(
487+
"in: {}",
488+
model_detail_link(
489+
latest.container.publishable_entity.learning_package,
490+
latest.container.publishable_entity.learning_package.key
491+
)
492+
)
493+
else:
494+
return None
495+
496+
# We'd like it to appear as if these three columns are just a single
497+
# nicely-formatted column, so only give the left one a description.
498+
recent_container_version_num.short_description = ( # type: ignore[attr-defined]
499+
"Most recent container version using this entity list"
500+
)
501+
recent_container.short_description = "" # type: ignore[attr-defined]
502+
recent_container_package.short_description = "" # type: ignore[attr-defined]
503+
504+
505+
@functools.cache
506+
def _latest_container_version(obj: EntityList) -> ContainerVersion | None:
507+
"""
508+
Any given EntityList can be used by multiple ContainerVersion (which may even
509+
span multiple Containers). We only have space here to show one ContainerVersion
510+
easily, so let's show the one that's most likely to be interesting to the Django
511+
admin user: the most-recently-created one.
512+
"""
513+
return obj.container_versions.order_by("-pk").first()

openedx_learning/apps/authoring/publishing/models/publishable_entity.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,10 @@ def title(self) -> str:
570570
def created(self) -> datetime:
571571
return self.publishable_entity_version.created
572572

573+
@property
574+
def created_by(self):
575+
return self.publishable_entity_version.created_by
576+
573577
@property
574578
def version_num(self) -> int:
575579
return self.publishable_entity_version.version_num
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Django admin for sections models
3+
"""
4+
from django.contrib import admin
5+
from django.utils.safestring import SafeText
6+
7+
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link
8+
9+
from .models import Section, SectionVersion
10+
11+
12+
class SectionVersionInline(admin.TabularInline):
13+
"""
14+
Minimal table for subsecdtion versions in a subsection
15+
"""
16+
model = SectionVersion
17+
18+
19+
@admin.register(Section)
20+
class SectionAdmin(ReadOnlyModelAdmin):
21+
"""
22+
Very minimal interface... just direct the admin user's attention towards the related Container model admin.
23+
"""
24+
list_display = ["section_id", "key"]
25+
fields = ["see"]
26+
readonly_fields = ["see"]
27+
inlines = [SectionVersionInline]
28+
29+
def section_id(self, obj: Section) -> int:
30+
return obj.pk
31+
32+
def key(self, obj: Section) -> SafeText:
33+
return model_detail_link(obj.container, obj.container.key)
34+
35+
def see(self, obj: Section) -> SafeText:
36+
return self.key(obj)

openedx_learning/apps/authoring/sections/apps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""
2-
Subsection Django application initialization.
2+
Sections Django application initialization.
33
"""
44

55
from django.apps import AppConfig
66

77

88
class SectionsConfig(AppConfig):
99
"""
10-
Configuration for the subsections Django application.
10+
Configuration for the Sections Django application.
1111
"""
1212

1313
name = "openedx_learning.apps.authoring.sections"

0 commit comments

Comments
 (0)