Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Open edX Learning ("Learning Core").
"""

__version__ = "0.26.0"
__version__ = "0.27.0"
284 changes: 273 additions & 11 deletions openedx_learning/apps/authoring/publishing/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit/optional: I think the uuid is not very useful, and the title is, so I suggest taking out the former and adding the latter.

Suggested change
"Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list)
'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list)

Screenshot:

Screenshot 2025-07-16 at 3 19 36 PM

vs.

Screenshot 2025-07-16 at 3 20 07 PM

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Changed for both Draft and Published.

I also removed all other occurrences of UUID from the various container-related admin lists, detail views, and inlines. We're not using the UUIDs for anything right now, so I'd rather it not clutter up the useful information with something irrelevant. If an operator really needs to know something's UUID, it is available on the linked PublishableEntity detail view.

)
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("-")
Comment thread
kdmccormick marked this conversation as resolved.
Outdated


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(
"{}<ul>{}</ul>",
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(
"{}<ul>{}</ul>",
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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions openedx_learning/apps/authoring/sections/admin.py
Original file line number Diff line number Diff line change
@@ -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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think the default title used in the admin interface isn't great, and it looks nicer if we override __str__ on the model to pull the title from the latest draft.

with __str__
Screenshot 2025-07-16 at 3 01 06 PM Screenshot 2025-07-16 at 3 00 35 PM

@kdmccormick kdmccormick Jul 21, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counterproposal... given that PEs already stringify to their key:

class PublishableEntity(models.Model):
    ...
    def __str__(self):
        return f"{self.key}"

how about I make PEVs stringify like this:

class PublishableEntityVersion(models.Model):
    ...
    def __str__(self):
        return f"{self.entity.key} @ v{self.version_num}"

and then make it so their mixin models default to the same behavior?

class PublishableEntityMixin(models.Model):
    ...
    def __str__(self) -> str:
        return str(self.publishable_entity) 

class PublishableEntityVersionMixin(models.Model):
    ...
    def __str__(self) -> str:
        return str(self.publishable_entity_version)

I know that the titles are nicer sometimes, but I feel that the keys and version nums better represent the models.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe have the title at the end?

    # <bikeshed>
    def __str__(self):
        return f"{self.entity.key} @ v{self.version_num} - {self.title}"
    # </bikeshed>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it makes the inline a little busy, but that's fine
Screenshot 2025-07-21 at 1 22 00 PM

"""
Very minimal interface... just direct the admin user's attention towards the related Container model admin.
"""
list_display = ["section_id", "key"]
fields = ["see"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I found "See" to be a bit ambiguous at first.

Screenshot 2025-07-16 at 3 13 40 PM

I think this would be more clear:

Screenshot 2025-07-16 at 3 12 31 PM

Which can be accomplished by overriding get_form on the Section/Subsection/Unit admin class.

    def get_form(self, request, obj=None, change=False, **kwargs):
        help_texts = {'container': 'To see details of this section, click above to see its container view.'}
        kwargs.update({'help_texts': help_texts})
        return super().get_form(request, obj, **kwargs)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I applied this to each core container type, with some tweaks to ensure that it doesn't seem like "Container" refers to some container that holds this as a child.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-07-21 at 1 25 12 PM

readonly_fields = ["see"]
inlines = [SectionVersionInline]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these inlines are very useful. The one you see when you click on the "See" link to view the Container admin has way more data and is much more useful. So I think it's better to just remove this so people don't waste their time reviewing the data on this page, and click through to the Container page.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had initially omitted this inline, but I ended up needing to add it in order to diagnose an bug where the modulestore_migrator was creating ContainerVersions but not their connected subclasses (SectionVersion, etc.). As long as it's possible to create a section's ContainerVersion but not its SectionVersion, I'd like to keep this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, that makes sense. Maybe mention that in a comment so others don't have the same question as me in the future?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, done


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)
4 changes: 2 additions & 2 deletions openedx_learning/apps/authoring/sections/apps.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""
Subsection Django application initialization.
Sections Django application initialization.
"""

from django.apps import AppConfig


class SectionsConfig(AppConfig):
"""
Configuration for the subsections Django application.
Configuration for the Sections Django application.
"""

name = "openedx_learning.apps.authoring.sections"
Expand Down
Loading