From 13223a7686856d160c1a366ef9f2de097dfa4bdd Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 13 May 2025 16:17:30 -0700 Subject: [PATCH 1/3] feat: Browsable Django Admin interface for Containers 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. --- openedx_learning/__init__.py | 2 +- .../apps/authoring/publishing/admin.py | 284 +++++++++++++++++- .../publishing/models/publishable_entity.py | 4 + .../apps/authoring/sections/admin.py | 36 +++ .../apps/authoring/sections/apps.py | 4 +- .../apps/authoring/subsections/admin.py | 36 +++ .../apps/authoring/units/admin.py | 36 +++ openedx_learning/lib/admin_utils.py | 18 +- 8 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 openedx_learning/apps/authoring/sections/admin.py create mode 100644 openedx_learning/apps/authoring/subsections/admin.py create mode 100644 openedx_learning/apps/authoring/units/admin.py diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index be9e6c288..bd62127ba 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -2,4 +2,4 @@ Open edX Learning ("Learning Core"). """ -__version__ = "0.26.0" +__version__ = "0.27.0" diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index a710536c8..382ed83ff 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -3,14 +3,22 @@ """ from __future__ import annotations +import functools + from django.contrib import admin from django.db.models import Count +from django.utils.html import format_html +from django.utils.safestring import SafeText -from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link, one_to_one_related_model_html from .models import ( + Container, + ContainerVersion, DraftChangeLog, DraftChangeLogRecord, + EntityList, + EntityListRow, LearningPackage, PublishableEntity, PublishLog, @@ -122,6 +130,12 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin): "can_stand_alone", ] + def draft_version(self, entity: PublishableEntity): + return entity.draft.version.version_num if entity.draft.version else None + + def published_version(self, entity: PublishableEntity): + return entity.published.version.version_num if entity.published and entity.published.version else None + def get_queryset(self, request): queryset = super().get_queryset(request) return queryset.select_related( @@ -131,16 +145,6 @@ def get_queryset(self, request): def see_also(self, entity): return one_to_one_related_model_html(entity) - def draft_version(self, entity): - if entity.draft.version: - return entity.draft.version.version_num - return None - - def published_version(self, entity): - if entity.published.version: - return entity.published.version.version_num - return None - @admin.register(Published) class PublishedAdmin(ReadOnlyModelAdmin): @@ -246,3 +250,261 @@ def get_queryset(self, request): queryset = super().get_queryset(request) return queryset.select_related("learning_package", "changed_by") \ .annotate(num_changes=Count("records")) + + +def _entity_list_detail_link(el: EntityList) -> SafeText: + """ + A link to the detail page for an EntityList which includes its PK and length. + """ + return model_detail_link(el, f"EntityList #{el.pk} with {el.entitylistrow_set.count()} row(s)") + + +class ContainerVersionInlineForContainer(admin.TabularInline): + """ + Inline admin view of ContainerVersions in a given Container + """ + model = ContainerVersion + ordering = ["-publishable_entity_version__version_num"] + fields = [ + "uuid", + "version_num", + "title", + "children", + "created", + "created_by", + ] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "publishable_entity_version" + ) + + def children(self, obj: ContainerVersion): + return _entity_list_detail_link(obj.entity_list) + + +@admin.register(Container) +class ContainerAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for Container + """ + list_display = ("key", "uuid", "created", "draft", "published", "see_also") + fields = [ + "publishable_entity", + "learning_package", + "draft", + "published", + "created", + "created_by", + "see_also", + "most_recent_parent_entity_list", + ] + readonly_fields = fields # type: ignore[assignment] + search_fields = ["publishable_entity__uuid", "publishable_entity__key"] + inlines = [ContainerVersionInlineForContainer] + + def uuid(self, obj: Container) -> SafeText: + return model_detail_link(obj, obj.uuid) + + def learning_package(self, obj: Container) -> SafeText: + return model_detail_link( + obj.publishable_entity.learning_package, + obj.publishable_entity.learning_package.key, + ) + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "publishable_entity", + "publishable_entity__learning_package", + "publishable_entity__published__version", + "publishable_entity__draft__version", + ) + + def draft(self, obj: Container) -> SafeText: + """ + Link to this Container's draft ContainerVersion + """ + if draft := obj.versioning.draft: + return format_html( + "Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list) + ) + return SafeText("-") + + def published(self, obj: Container) -> SafeText: + """ + Link to this Container's published ContainerVersion + """ + if published := obj.versioning.published: + return format_html( + "Version {} ({})", published.version_num, _entity_list_detail_link(published.entity_list) + ) + return SafeText("-") + + def see_also(self, obj: Container): + return one_to_one_related_model_html(obj) + + def most_recent_parent_entity_list(self, obj: Container) -> SafeText: + if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first(): + return _entity_list_detail_link(latest_row.entity_list) + return SafeText("-") + + +class ContainerVersionInlineForEntityList(admin.TabularInline): + """ + Inline admin view of ContainerVersions which use a given EntityList + """ + model = ContainerVersion + verbose_name = "Container Version that references this Entity List" + verbose_name_plural = "Container Versions that reference this Entity List" + ordering = ["-pk"] # Newest first + fields = [ + "uuid", + "version_num", + "container_key", + "title", + "created", + "created_by", + ] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "container", + "container__publishable_entity", + "publishable_entity_version", + ) + + def container_key(self, obj: ContainerVersion) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + +class EntityListRowInline(admin.TabularInline): + """ + Table of entity rows in the entitylist admin + """ + model = EntityListRow + readonly_fields = [ + "order_num", + "pinned_version_num", + "entity_models", + "container_models", + "container_children", + ] + fields = readonly_fields # type: ignore[assignment] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "entity", + "entity_version", + ) + + def pinned_version_num(self, obj: EntityListRow): + return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)" + + def entity_models(self, obj: EntityListRow): + return format_html( + "{}", + model_detail_link(obj.entity, obj.entity.key), + one_to_one_related_model_html(obj.entity), + ) + + def container_models(self, obj: EntityListRow) -> SafeText: + if not hasattr(obj.entity, "container"): + return SafeText("(Not a Container)") + return format_html( + "{}", + model_detail_link(obj.entity.container, str(obj.entity.container)), + one_to_one_related_model_html(obj.entity.container), + ) + + def container_children(self, obj: EntityListRow) -> SafeText: + """ + If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing. + + When determining which ContainerVersion to grab the EntityList from, prefer the pinned + version if there is one; otherwise use the Draft version. + """ + if not hasattr(obj.entity, "container"): + return SafeText("(Not a Container)") + child_container_version: ContainerVersion = ( + obj.entity_version.containerversion + if obj.entity_version + else obj.entity.container.versioning.draft + ) + return _entity_list_detail_link(child_container_version.entity_list) + + +@admin.register(EntityList) +class EntityListAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for EntityList + """ + list_display = [ + "entity_list", + "row_count", + "recent_container_version_num", + "recent_container", + "recent_container_package" + ] + inlines = [ContainerVersionInlineForEntityList, EntityListRowInline] + + def entity_list(self, obj: EntityList) -> SafeText: + return model_detail_link(obj, f"EntityList #{obj.pk}") + + def row_count(self, obj: EntityList) -> int: + return obj.entitylistrow_set.count() + + def recent_container_version_num(self, obj: EntityList) -> str: + """ + Number of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return f"Version {latest.version_num}" + else: + return "-" + + def recent_container(self, obj: EntityList) -> SafeText | None: + """ + Link to the Container of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return format_html("of: {}", model_detail_link(latest.container, latest.container.key)) + else: + return None + + def recent_container_package(self, obj: EntityList) -> SafeText | None: + """ + Link to the LearningPackage of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return format_html( + "in: {}", + model_detail_link( + latest.container.publishable_entity.learning_package, + latest.container.publishable_entity.learning_package.key + ) + ) + else: + return None + + # We'd like it to appear as if these three columns are just a single + # nicely-formatted column, so only give the left one a description. + recent_container_version_num.short_description = ( # type: ignore[attr-defined] + "Most recent container version using this entity list" + ) + recent_container.short_description = "" # type: ignore[attr-defined] + recent_container_package.short_description = "" # type: ignore[attr-defined] + + +@functools.cache +def _latest_container_version(obj: EntityList) -> ContainerVersion | None: + """ + Any given EntityList can be used by multiple ContainerVersion (which may even + span multiple Containers). We only have space here to show one ContainerVersion + easily, so let's show the one that's most likely to be interesting to the Django + admin user: the most-recently-created one. + """ + return obj.container_versions.order_by("-pk").first() diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py index 9a53f22cb..60c5fe1a9 100644 --- a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -570,6 +570,10 @@ def title(self) -> str: def created(self) -> datetime: return self.publishable_entity_version.created + @property + def created_by(self): + return self.publishable_entity_version.created_by + @property def version_num(self) -> int: return self.publishable_entity_version.version_num diff --git a/openedx_learning/apps/authoring/sections/admin.py b/openedx_learning/apps/authoring/sections/admin.py new file mode 100644 index 000000000..48f16fe75 --- /dev/null +++ b/openedx_learning/apps/authoring/sections/admin.py @@ -0,0 +1,36 @@ +""" +Django admin for sections models +""" +from django.contrib import admin +from django.utils.safestring import SafeText + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link + +from .models import Section, SectionVersion + + +class SectionVersionInline(admin.TabularInline): + """ + Minimal table for subsecdtion versions in a subsection + """ + model = SectionVersion + + +@admin.register(Section) +class SectionAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["section_id", "key"] + fields = ["see"] + readonly_fields = ["see"] + inlines = [SectionVersionInline] + + def section_id(self, obj: Section) -> int: + return obj.pk + + def key(self, obj: Section) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: Section) -> SafeText: + return self.key(obj) diff --git a/openedx_learning/apps/authoring/sections/apps.py b/openedx_learning/apps/authoring/sections/apps.py index 64bc5f871..42c8d6ec9 100644 --- a/openedx_learning/apps/authoring/sections/apps.py +++ b/openedx_learning/apps/authoring/sections/apps.py @@ -1,5 +1,5 @@ """ -Subsection Django application initialization. +Sections Django application initialization. """ from django.apps import AppConfig @@ -7,7 +7,7 @@ class SectionsConfig(AppConfig): """ - Configuration for the subsections Django application. + Configuration for the Sections Django application. """ name = "openedx_learning.apps.authoring.sections" diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py new file mode 100644 index 000000000..62b2802f3 --- /dev/null +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -0,0 +1,36 @@ +""" +Django admin for subsection models +""" +from django.contrib import admin +from django.utils.safestring import SafeText + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link + +from .models import Subsection, SubsectionVersion + + +class SubsectionVersionInline(admin.TabularInline): + """ + Minimal table for subsecdtion versions in a subsection + """ + model = SubsectionVersion + + +@admin.register(Subsection) +class SubsectionAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["subsection_id", "key"] + fields = ["see"] + readonly_fields = ["see"] + inlines = [SubsectionVersionInline] + + def subsection_id(self, obj: Subsection) -> int: + return obj.pk + + def key(self, obj: Subsection) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: Subsection) -> SafeText: + return self.key(obj) diff --git a/openedx_learning/apps/authoring/units/admin.py b/openedx_learning/apps/authoring/units/admin.py new file mode 100644 index 000000000..a1fb7f47a --- /dev/null +++ b/openedx_learning/apps/authoring/units/admin.py @@ -0,0 +1,36 @@ +""" +Django admin for units models +""" +from django.contrib import admin +from django.utils.safestring import SafeText + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link + +from .models import Unit, UnitVersion + + +class UnitVersionInline(admin.TabularInline): + """ + Minimal table for unit versions in a unit + """ + model = UnitVersion + + +@admin.register(Unit) +class UnitAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["unit_id", "container_key"] + fields = ["see"] + readonly_fields = ["see"] + inlines = [UnitVersionInline] + + def unit_id(self, obj: Unit) -> int: + return obj.pk + + def container_key(self, obj: Unit) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: Unit) -> SafeText: + return self.container_key(obj) diff --git a/openedx_learning/lib/admin_utils.py b/openedx_learning/lib/admin_utils.py index ece0e3a45..ae7602d61 100644 --- a/openedx_learning/lib/admin_utils.py +++ b/openedx_learning/lib/admin_utils.py @@ -2,9 +2,11 @@ Convenience utilities for the Django Admin. """ from django.contrib import admin +from django.db import models from django.db.models.fields.reverse_related import OneToOneRel from django.urls import NoReverseMatch, reverse from django.utils.html import format_html, format_html_join +from django.utils.safestring import SafeText class ReadOnlyModelAdmin(admin.ModelAdmin): @@ -31,7 +33,7 @@ def has_delete_permission(self, request, obj=None): return False -def one_to_one_related_model_html(model_obj): +def one_to_one_related_model_html(model_obj: models.Model) -> SafeText: """ HTML for clickable list of a models that are 1:1-related to ``model_obj``. @@ -98,3 +100,17 @@ def one_to_one_related_model_html(model_obj): text.append(html) return format_html_join("\n", "
  • {}
  • ", ((t,) for t in text)) + + +def model_detail_link(obj: models.Model, link_text: str) -> SafeText: + """ + Render an HTML link to the admin focus page for `obj`. + """ + return format_html( + '{}', + reverse( + f"admin:{obj._meta.app_label}_{(obj._meta.model_name or obj.__class__.__name__).lower()}_change", + args=(obj.pk,), + ), + link_text, + ) From 964b48a8bdfa0b32ba2f978631e657948cd02c42 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 21 Jul 2025 13:23:05 -0400 Subject: [PATCH 2/3] squash!: code review --- .../apps/authoring/publishing/admin.py | 35 ++++++++++--------- .../publishing/models/publishable_entity.py | 9 +++++ .../apps/authoring/sections/admin.py | 24 ++++++++----- .../apps/authoring/subsections/admin.py | 24 ++++++++----- .../apps/authoring/units/admin.py | 26 ++++++++------ 5 files changed, 74 insertions(+), 44 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index 382ed83ff..7b144fa8c 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -233,7 +233,7 @@ class DraftChangeSetAdmin(ReadOnlyModelAdmin): """ inlines = [DraftChangeLogRecordTabularInline] fields = ( - "uuid", + "pk", "learning_package", "num_changes", "changed_at", @@ -256,7 +256,9 @@ def _entity_list_detail_link(el: EntityList) -> SafeText: """ A link to the detail page for an EntityList which includes its PK and length. """ - return model_detail_link(el, f"EntityList #{el.pk} with {el.entitylistrow_set.count()} row(s)") + num_rows = el.entitylistrow_set.count() + rows_noun = "row" if num_rows == 1 else "rows" + return model_detail_link(el, f"EntityList #{el.pk} with {num_rows} {rows_noun}") class ContainerVersionInlineForContainer(admin.TabularInline): @@ -266,7 +268,7 @@ class ContainerVersionInlineForContainer(admin.TabularInline): model = ContainerVersion ordering = ["-publishable_entity_version__version_num"] fields = [ - "uuid", + "pk", "version_num", "title", "children", @@ -290,8 +292,9 @@ class ContainerAdmin(ReadOnlyModelAdmin): """ Django admin configuration for Container """ - list_display = ("key", "uuid", "created", "draft", "published", "see_also") + list_display = ("key", "created", "draft", "published", "see_also") fields = [ + "pk", "publishable_entity", "learning_package", "draft", @@ -305,9 +308,6 @@ class ContainerAdmin(ReadOnlyModelAdmin): search_fields = ["publishable_entity__uuid", "publishable_entity__key"] inlines = [ContainerVersionInlineForContainer] - def uuid(self, obj: Container) -> SafeText: - return model_detail_link(obj, obj.uuid) - def learning_package(self, obj: Container) -> SafeText: return model_detail_link( obj.publishable_entity.learning_package, @@ -322,33 +322,36 @@ def get_queryset(self, request): "publishable_entity__draft__version", ) - def draft(self, obj: Container) -> SafeText: + def draft(self, obj: Container) -> str: """ Link to this Container's draft ContainerVersion """ if draft := obj.versioning.draft: return format_html( - "Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list) + 'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list) ) - return SafeText("-") + return "-" - def published(self, obj: Container) -> SafeText: + def published(self, obj: Container) -> str: """ Link to this Container's published ContainerVersion """ if published := obj.versioning.published: return format_html( - "Version {} ({})", published.version_num, _entity_list_detail_link(published.entity_list) + 'Version {} "{}" ({})', + published.version_num, + published.title, + _entity_list_detail_link(published.entity_list), ) - return SafeText("-") + return "-" def see_also(self, obj: Container): return one_to_one_related_model_html(obj) - def most_recent_parent_entity_list(self, obj: Container) -> SafeText: + def most_recent_parent_entity_list(self, obj: Container) -> str: if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first(): return _entity_list_detail_link(latest_row.entity_list) - return SafeText("-") + return "-" class ContainerVersionInlineForEntityList(admin.TabularInline): @@ -360,7 +363,7 @@ class ContainerVersionInlineForEntityList(admin.TabularInline): verbose_name_plural = "Container Versions that reference this Entity List" ordering = ["-pk"] # Newest first fields = [ - "uuid", + "pk", "version_num", "container_key", "title", diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py index 60c5fe1a9..87619ee69 100644 --- a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -221,6 +221,9 @@ class PublishableEntityVersion(models.Model): blank=True, ) + def __str__(self): + return f"{self.entity.key} @ v{self.version_num} - {self.title}" + class Meta: constraints = [ # Prevent the situation where we have multiple @@ -303,6 +306,9 @@ def created(self) -> datetime: def created_by(self): return self.publishable_entity.created_by + def __str__(self) -> str: + return str(self.publishable_entity) + class Meta: abstract = True @@ -578,6 +584,9 @@ def created_by(self): def version_num(self) -> int: return self.publishable_entity_version.version_num + def __str__(self) -> str: + return str(self.publishable_entity_version) + class Meta: abstract = True diff --git a/openedx_learning/apps/authoring/sections/admin.py b/openedx_learning/apps/authoring/sections/admin.py index 48f16fe75..a2784d8e5 100644 --- a/openedx_learning/apps/authoring/sections/admin.py +++ b/openedx_learning/apps/authoring/sections/admin.py @@ -6,6 +6,7 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link +from ..publishing.models import ContainerVersion from .models import Section, SectionVersion @@ -14,6 +15,12 @@ class SectionVersionInline(admin.TabularInline): Minimal table for subsecdtion versions in a subsection """ model = SectionVersion + fields = ["pk"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk @admin.register(Section) @@ -21,16 +28,15 @@ class SectionAdmin(ReadOnlyModelAdmin): """ Very minimal interface... just direct the admin user's attention towards the related Container model admin. """ - list_display = ["section_id", "key"] - fields = ["see"] - readonly_fields = ["see"] inlines = [SectionVersionInline] - - def section_id(self, obj: Section) -> int: - return obj.pk + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] def key(self, obj: Section) -> SafeText: - return model_detail_link(obj.container, obj.container.key) + return model_detail_link(obj.container, obj.key) - def see(self, obj: Section) -> SafeText: - return self.key(obj) + def get_form(self, request, obj=None, change=False, **kwargs): + help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'} + kwargs.update({'help_texts': help_texts}) + return super().get_form(request, obj, **kwargs) diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py index 62b2802f3..ce223c11f 100644 --- a/openedx_learning/apps/authoring/subsections/admin.py +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -6,6 +6,7 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link +from ..publishing.models import ContainerVersion from .models import Subsection, SubsectionVersion @@ -14,6 +15,12 @@ class SubsectionVersionInline(admin.TabularInline): Minimal table for subsecdtion versions in a subsection """ model = SubsectionVersion + fields = ["pk"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk @admin.register(Subsection) @@ -21,16 +28,15 @@ class SubsectionAdmin(ReadOnlyModelAdmin): """ Very minimal interface... just direct the admin user's attention towards the related Container model admin. """ - list_display = ["subsection_id", "key"] - fields = ["see"] - readonly_fields = ["see"] inlines = [SubsectionVersionInline] - - def subsection_id(self, obj: Subsection) -> int: - return obj.pk + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] def key(self, obj: Subsection) -> SafeText: - return model_detail_link(obj.container, obj.container.key) + return model_detail_link(obj.container, obj.key) - def see(self, obj: Subsection) -> SafeText: - return self.key(obj) + def get_form(self, request, obj=None, change=False, **kwargs): + help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'} + kwargs.update({'help_texts': help_texts}) + return super().get_form(request, obj, **kwargs) diff --git a/openedx_learning/apps/authoring/units/admin.py b/openedx_learning/apps/authoring/units/admin.py index a1fb7f47a..cb0f32727 100644 --- a/openedx_learning/apps/authoring/units/admin.py +++ b/openedx_learning/apps/authoring/units/admin.py @@ -6,6 +6,7 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link +from ..publishing.models import ContainerVersion from .models import Unit, UnitVersion @@ -14,6 +15,12 @@ class UnitVersionInline(admin.TabularInline): Minimal table for unit versions in a unit """ model = UnitVersion + fields = ["pk"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk @admin.register(Unit) @@ -21,16 +28,15 @@ class UnitAdmin(ReadOnlyModelAdmin): """ Very minimal interface... just direct the admin user's attention towards the related Container model admin. """ - list_display = ["unit_id", "container_key"] - fields = ["see"] - readonly_fields = ["see"] inlines = [UnitVersionInline] + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] - def unit_id(self, obj: Unit) -> int: - return obj.pk - - def container_key(self, obj: Unit) -> SafeText: - return model_detail_link(obj.container, obj.container.key) + def key(self, obj: Unit) -> SafeText: + return model_detail_link(obj.container, obj.key) - def see(self, obj: Unit) -> SafeText: - return self.container_key(obj) + def get_form(self, request, obj=None, change=False, **kwargs): + help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'} + kwargs.update({'help_texts': help_texts}) + return super().get_form(request, obj, **kwargs) From 7607534e7765b3719a2381b7a677cd094ece1124 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 21 Jul 2025 13:30:59 -0400 Subject: [PATCH 3/3] docs: comment about redundant containerversion inline tables --- openedx_learning/apps/authoring/sections/admin.py | 8 +++++++- openedx_learning/apps/authoring/subsections/admin.py | 8 +++++++- openedx_learning/apps/authoring/units/admin.py | 6 ++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/openedx_learning/apps/authoring/sections/admin.py b/openedx_learning/apps/authoring/sections/admin.py index a2784d8e5..af35c180e 100644 --- a/openedx_learning/apps/authoring/sections/admin.py +++ b/openedx_learning/apps/authoring/sections/admin.py @@ -12,7 +12,13 @@ class SectionVersionInline(admin.TabularInline): """ - Minimal table for subsecdtion versions in a subsection + Minimal table for section versions in a section. + + (Generally, this information is useless, because each SectionVersion should have a + matching ContainerVersion, shown in much more detail on the Container detail page. + But we've hit at least one bug where ContainerVersions were being created without + their connected SectionVersions, so we'll leave this table here for debugging + at least until we've made the APIs more robust against that sort of data corruption.) """ model = SectionVersion fields = ["pk"] diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py index ce223c11f..50c6ad620 100644 --- a/openedx_learning/apps/authoring/subsections/admin.py +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -12,7 +12,13 @@ class SubsectionVersionInline(admin.TabularInline): """ - Minimal table for subsecdtion versions in a subsection + Minimal table for subsection versions in a subsection. + + (Generally, this information is useless, because each SubsectionVersion should have a + matching ContainerVersion, shown in much more detail on the Container detail page. + But we've hit at least one bug where ContainerVersions were being created without + their connected SubsectionVersions, so we'll leave this table here for debugging + at least until we've made the APIs more robust against that sort of data corruption.) """ model = SubsectionVersion fields = ["pk"] diff --git a/openedx_learning/apps/authoring/units/admin.py b/openedx_learning/apps/authoring/units/admin.py index cb0f32727..3ebd9a27c 100644 --- a/openedx_learning/apps/authoring/units/admin.py +++ b/openedx_learning/apps/authoring/units/admin.py @@ -13,6 +13,12 @@ class UnitVersionInline(admin.TabularInline): """ Minimal table for unit versions in a unit + + (Generally, this information is useless, because each UnitVersion should have a + matching ContainerVersion, shown in much more detail on the Container detail page. + But we've hit at least one bug where ContainerVersions were being created without + their connected UnitVersions, so we'll leave this table here for debugging + at least until we've made the APIs more robust against that sort of data corruption.) """ model = UnitVersion fields = ["pk"]