Skip to content

Commit a347d40

Browse files
authored
feat: Browsable Django Admin interface for Containers (#330)
This adds several admin pages which were previously missing or barebones: Section, Subsection, Unit, Container, and EntityList. The Container and EntityList pages have inlines tables to display their related ContainerVersions and EntityListsRows. This enables developers/operators to browse up and down the content hierarchies that are stored in Learning Core.
1 parent c3b89c5 commit a347d40

File tree

8 files changed

+454
-16
lines changed

8 files changed

+454
-16
lines changed

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,14 +3,22 @@
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
13+
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link, one_to_one_related_model_html
1014

1115
from .models import (
16+
Container,
17+
ContainerVersion,
1218
DraftChangeLog,
1319
DraftChangeLogRecord,
20+
EntityList,
21+
EntityListRow,
1422
LearningPackage,
1523
PublishableEntity,
1624
PublishLog,
@@ -122,6 +130,12 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
122130
"can_stand_alone",
123131
]
124132

133+
def draft_version(self, entity: PublishableEntity):
134+
return entity.draft.version.version_num if entity.draft.version else None
135+
136+
def published_version(self, entity: PublishableEntity):
137+
return entity.published.version.version_num if entity.published and entity.published.version else None
138+
125139
def get_queryset(self, request):
126140
queryset = super().get_queryset(request)
127141
return queryset.select_related(
@@ -131,16 +145,6 @@ def get_queryset(self, request):
131145
def see_also(self, entity):
132146
return one_to_one_related_model_html(entity)
133147

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-
144148

145149
@admin.register(Published)
146150
class PublishedAdmin(ReadOnlyModelAdmin):
@@ -229,7 +233,7 @@ class DraftChangeSetAdmin(ReadOnlyModelAdmin):
229233
"""
230234
inlines = [DraftChangeLogRecordTabularInline]
231235
fields = (
232-
"uuid",
236+
"pk",
233237
"learning_package",
234238
"num_changes",
235239
"changed_at",
@@ -246,3 +250,264 @@ def get_queryset(self, request):
246250
queryset = super().get_queryset(request)
247251
return queryset.select_related("learning_package", "changed_by") \
248252
.annotate(num_changes=Count("records"))
253+
254+
255+
def _entity_list_detail_link(el: EntityList) -> SafeText:
256+
"""
257+
A link to the detail page for an EntityList which includes its PK and length.
258+
"""
259+
num_rows = el.entitylistrow_set.count()
260+
rows_noun = "row" if num_rows == 1 else "rows"
261+
return model_detail_link(el, f"EntityList #{el.pk} with {num_rows} {rows_noun}")
262+
263+
264+
class ContainerVersionInlineForContainer(admin.TabularInline):
265+
"""
266+
Inline admin view of ContainerVersions in a given Container
267+
"""
268+
model = ContainerVersion
269+
ordering = ["-publishable_entity_version__version_num"]
270+
fields = [
271+
"pk",
272+
"version_num",
273+
"title",
274+
"children",
275+
"created",
276+
"created_by",
277+
]
278+
readonly_fields = fields # type: ignore[assignment]
279+
extra = 0
280+
281+
def get_queryset(self, request):
282+
return super().get_queryset(request).select_related(
283+
"publishable_entity_version"
284+
)
285+
286+
def children(self, obj: ContainerVersion):
287+
return _entity_list_detail_link(obj.entity_list)
288+
289+
290+
@admin.register(Container)
291+
class ContainerAdmin(ReadOnlyModelAdmin):
292+
"""
293+
Django admin configuration for Container
294+
"""
295+
list_display = ("key", "created", "draft", "published", "see_also")
296+
fields = [
297+
"pk",
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 learning_package(self, obj: Container) -> SafeText:
312+
return model_detail_link(
313+
obj.publishable_entity.learning_package,
314+
obj.publishable_entity.learning_package.key,
315+
)
316+
317+
def get_queryset(self, request):
318+
return super().get_queryset(request).select_related(
319+
"publishable_entity",
320+
"publishable_entity__learning_package",
321+
"publishable_entity__published__version",
322+
"publishable_entity__draft__version",
323+
)
324+
325+
def draft(self, obj: Container) -> str:
326+
"""
327+
Link to this Container's draft ContainerVersion
328+
"""
329+
if draft := obj.versioning.draft:
330+
return format_html(
331+
'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list)
332+
)
333+
return "-"
334+
335+
def published(self, obj: Container) -> str:
336+
"""
337+
Link to this Container's published ContainerVersion
338+
"""
339+
if published := obj.versioning.published:
340+
return format_html(
341+
'Version {} "{}" ({})',
342+
published.version_num,
343+
published.title,
344+
_entity_list_detail_link(published.entity_list),
345+
)
346+
return "-"
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) -> str:
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 "-"
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+
"pk",
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ class PublishableEntityVersion(models.Model):
221221
blank=True,
222222
)
223223

224+
def __str__(self):
225+
return f"{self.entity.key} @ v{self.version_num} - {self.title}"
226+
224227
class Meta:
225228
constraints = [
226229
# Prevent the situation where we have multiple
@@ -303,6 +306,9 @@ def created(self) -> datetime:
303306
def created_by(self):
304307
return self.publishable_entity.created_by
305308

309+
def __str__(self) -> str:
310+
return str(self.publishable_entity)
311+
306312
class Meta:
307313
abstract = True
308314

@@ -570,10 +576,17 @@ def title(self) -> str:
570576
def created(self) -> datetime:
571577
return self.publishable_entity_version.created
572578

579+
@property
580+
def created_by(self):
581+
return self.publishable_entity_version.created_by
582+
573583
@property
574584
def version_num(self) -> int:
575585
return self.publishable_entity_version.version_num
576586

587+
def __str__(self) -> str:
588+
return str(self.publishable_entity_version)
589+
577590
class Meta:
578591
abstract = True
579592

0 commit comments

Comments
 (0)