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..7b144fa8c 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): @@ -229,7 +233,7 @@ class DraftChangeSetAdmin(ReadOnlyModelAdmin): """ inlines = [DraftChangeLogRecordTabularInline] fields = ( - "uuid", + "pk", "learning_package", "num_changes", "changed_at", @@ -246,3 +250,264 @@ 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. + """ + 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): + """ + Inline admin view of ContainerVersions in a given Container + """ + model = ContainerVersion + ordering = ["-publishable_entity_version__version_num"] + fields = [ + "pk", + "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", "created", "draft", "published", "see_also") + fields = [ + "pk", + "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 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) -> str: + """ + Link to this Container's draft ContainerVersion + """ + if draft := obj.versioning.draft: + return format_html( + 'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list) + ) + return "-" + + 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, + published.title, + _entity_list_detail_link(published.entity_list), + ) + return "-" + + def see_also(self, obj: Container): + return one_to_one_related_model_html(obj) + + 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 "-" + + +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 = [ + "pk", + "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..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 @@ -570,10 +576,17 @@ 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 + 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 new file mode 100644 index 000000000..af35c180e --- /dev/null +++ b/openedx_learning/apps/authoring/sections/admin.py @@ -0,0 +1,48 @@ +""" +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 ..publishing.models import ContainerVersion +from .models import Section, SectionVersion + + +class SectionVersionInline(admin.TabularInline): + """ + 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"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk + + +@admin.register(Section) +class SectionAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + inlines = [SectionVersionInline] + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] + + def key(self, obj: Section) -> SafeText: + return model_detail_link(obj.container, obj.key) + + 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/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..50c6ad620 --- /dev/null +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -0,0 +1,48 @@ +""" +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 ..publishing.models import ContainerVersion +from .models import Subsection, SubsectionVersion + + +class SubsectionVersionInline(admin.TabularInline): + """ + 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"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk + + +@admin.register(Subsection) +class SubsectionAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + inlines = [SubsectionVersionInline] + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] + + def key(self, obj: Subsection) -> SafeText: + return model_detail_link(obj.container, obj.key) + + 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 new file mode 100644 index 000000000..3ebd9a27c --- /dev/null +++ b/openedx_learning/apps/authoring/units/admin.py @@ -0,0 +1,48 @@ +""" +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 ..publishing.models import ContainerVersion +from .models import Unit, UnitVersion + + +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"] + readonly_fields = ["pk"] + ordering = ["-pk"] # Newest first + + def pk(self, obj: ContainerVersion) -> SafeText: + return obj.pk + + +@admin.register(Unit) +class UnitAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + inlines = [UnitVersionInline] + list_display = ["pk", "key"] + fields = ["key"] + readonly_fields = ["key"] + + def key(self, obj: Unit) -> SafeText: + return model_detail_link(obj.container, obj.key) + + 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/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, + )