From 7dfcc1e2f4d27221aedce5d86ef12343080b8ed8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 13 May 2025 16:17:30 -0700 Subject: [PATCH 01/12] feat: OutlineRoot model --- openedx_learning/api/authoring.py | 1 + openedx_learning/api/authoring_models.py | 1 + .../apps/authoring/outline_roots/__init__.py | 0 .../apps/authoring/outline_roots/api.py | 264 ++++++++++++++++++ .../apps/authoring/outline_roots/apps.py | 25 ++ .../outline_roots/migrations/0001_initial.py | 36 +++ .../outline_roots/migrations/__init__.py | 0 .../apps/authoring/outline_roots/models.py | 59 ++++ .../apps/authoring/sections/apps.py | 4 +- projects/dev.py | 1 + test_settings.py | 1 + 11 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 openedx_learning/apps/authoring/outline_roots/__init__.py create mode 100644 openedx_learning/apps/authoring/outline_roots/api.py create mode 100644 openedx_learning/apps/authoring/outline_roots/apps.py create mode 100644 openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/outline_roots/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/outline_roots/models.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 5db14ceff..91adc70ca 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -12,6 +12,7 @@ from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * +from ..apps.authoring.outline_roots.api import * from ..apps.authoring.publishing.api import * from ..apps.authoring.sections.api import * from ..apps.authoring.subsections.api import * diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 617d85dc4..1e6738867 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,7 @@ from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * from ..apps.authoring.contents.models import * +from ..apps.authoring.outline_roots.models import * from ..apps.authoring.publishing.models import * from ..apps.authoring.sections.models import * from ..apps.authoring.subsections.models import * diff --git a/openedx_learning/apps/authoring/outline_roots/__init__.py b/openedx_learning/apps/authoring/outline_roots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/outline_roots/api.py b/openedx_learning/apps/authoring/outline_roots/api.py new file mode 100644 index 000000000..a0f8e54b5 --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/api.py @@ -0,0 +1,264 @@ +"""Outline Roots API. + +This module provides functions to manage outline roots. +""" +from dataclasses import dataclass +from datetime import datetime + +from django.db.transaction import atomic + +from openedx_learning.apps.authoring.sections.models import Section, SectionVersion +from openedx_learning.apps.authoring.subsections.models import Subsection, SubsectionVersion +from openedx_learning.apps.authoring.units.models import Unit, UnitVersion + +from ..publishing import api as publishing_api +from .models import OutlineRoot, OutlineRootVersion + +# 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured +# out our approach to dynamic content (randomized, A/B tests, etc.) +__all__ = [ + "create_outline_root", + "create_outline_root_version", + "create_next_outline_root_version", + "create_outline_root_and_version", + "get_outline_root", + "get_outline_root_version", + "OutlineRootListEntry", + "get_children_in_outline_root", +] + + +def create_outline_root( + learning_package_id: int, + key: str, + *, + created: datetime, + created_by: int | None, +) -> Section: + """ + [ 🛑 UNSTABLE ] Create a new section. + + Args: + learning_package_id: The learning package ID. + key: The key. + created: The creation date. + created_by: The user who created the section. + """ + return publishing_api.create_container( + learning_package_id, + key, + created, + created_by, + can_stand_alone=True, # Not created as part of another container. + container_cls=Section, + ) + + +def create_outline_root_version( + outline_root: OutlineRoot, + version_num: int, + *, + title: str, + entity_rows: list[publishing_api.ContainerEntityRow], + created: datetime, + created_by: int | None = None, +) -> OutlineRootVersion: + """ + [ 🛑 UNSTABLE ] Create a new OutlineRoot version. + + This is a very low-level API, likely only needed for import/export. In + general, you will use `create_outline_root_and_version()` and + `create_next_outline_root_version()` instead. + + Args: + outline_root: The OutlineRoot + version_num: The version number. + title: The title. + entity_rows: child entities/versions + created: The creation date. + created_by: The user who created this version of the outline root. + """ + return publishing_api.create_container_version( + outline_root.pk, + version_num, + title=title, + entity_rows=entity_rows, + created=created, + created_by=created_by, + container_version_cls=OutlineRootVersion, + ) + + +def _make_entity_rows( + children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None, +) -> list[publishing_api.ContainerEntityRow] | None: + """ + Helper method: given a list of children for the outline root, return the + lists of ContainerEntityRows (entity+version pairs) needed for the + base container APIs. + + *Version objects are passed when we want to pin a specific version, otherwise + Section/Subsection/Unit is used for unpinned. + """ + if children is None: + # When these are None, that means don't change the entities in the list. + return None + if not ( + all(isinstance(c, (Section, SectionVersion)) for c in children) or + all(isinstance(c, (Subsection, SubsectionVersion)) for c in children) or + all(isinstance(c, (Unit, UnitVersion)) for c in children) + ): + raise TypeError("OutlineRoot children must be Section[Version], Subsection[Version], or Unit[Version] objects.") + return [ + ( + publishing_api.ContainerEntityRow( + entity_pk=s.container.publishable_entity_id, + version_pk=None, + ) if isinstance(s, (Section, Subsection, Unit)) + else publishing_api.ContainerEntityRow( + entity_pk=s.container_version.container.publishable_entity_id, + version_pk=s.container_version.publishable_entity_version_id, + ) + ) + for s in children + ] + + +def create_next_outline_root_version( + outline_root: OutlineRoot, + *, + title: str | None = None, + children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None = None, # pylint: disable=line-too-long # noqa: E501 + created: datetime, + created_by: int | None = None, + entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE, +) -> OutlineRootVersion: + """ + [ 🛑 UNSTABLE ] Create the next OutlineRoot version. + + Args: + outline_root: The OutlineRoot + title: The title. Leave as None to keep the current title. + children: The children, usually a list of Sections. Pass SectionVersions to pin to specific versions. + Passing None will leave the existing children unchanged. + created: The creation date. + created_by: The user who created the section. + """ + entity_rows = _make_entity_rows(children) + return publishing_api.create_next_container_version( + outline_root.pk, + title=title, + entity_rows=entity_rows, + created=created, + created_by=created_by, + container_version_cls=OutlineRootVersion, + entities_action=entities_action, + ) + + +def create_outline_root_and_version( + learning_package_id: int, + key: str, + *, + title: str, + children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None = None, # pylint: disable=line-too-long # noqa: E501 + created: datetime, + created_by: int | None = None, +) -> tuple[OutlineRoot, OutlineRootVersion]: + """ + [ 🛑 UNSTABLE ] Create a new OutlineRoot and its version. + + Args: + learning_package_id: The learning package ID. + key: The key. + created: The creation date. + created_by: The user who created the section. + can_stand_alone: Set to False when created as part of containers + """ + entity_rows = _make_entity_rows(children) + with atomic(): + outline_root = create_outline_root( + learning_package_id, + key, + created=created, + created_by=created_by, + ) + version = create_outline_root_version( + outline_root, + 1, + title=title, + entity_rows=entity_rows or [], + created=created, + created_by=created_by, + ) + return outline_root, version + + +def get_outline_root(outline_root_pk: int) -> OutlineRoot: + """ + [ 🛑 UNSTABLE ] Get an OutlineRoot. + + Args: + outline_root_pk: The OutlineRoot ID. + """ + return OutlineRoot.objects.get(pk=outline_root_pk) + + +def get_outline_root_version(outline_root_version_pk: int) -> OutlineRootVersion: + """ + [ 🛑 UNSTABLE ] Get a OutlineRootVersion. + + Args: + outline_root_version_pk: The OutlineRootVersion ID. + """ + return OutlineRootVersion.objects.get(pk=outline_root_version_pk) + + +@dataclass(frozen=True) +class OutlineRootListEntry: + """ + [ 🛑 UNSTABLE ] + Data about a single entity in a container, e.g. a section in an outline root. + """ + child_version: SectionVersion | SubsectionVersion | UnitVersion + pinned: bool = False + + @property + def container(self): + return self.container_version.container + + @property + def container_version(self): + return self.child_version.container_version + + +def get_children_in_outline_root( + outline_root: OutlineRoot, + *, + published: bool, +) -> list[OutlineRootListEntry]: + """ + [ 🛑 UNSTABLE ] + Get the list of entities and their versions in the draft or published + version of the given OutlineRoot. + + Args: + outline_root: The OutlineRoot, e.g. returned by `get_outline_root()` + published: `True` if we want the published version of the OutlineRoot, + or `False` for the draft version. + """ + assert isinstance(outline_root, OutlineRoot) + children = [] + for entry in publishing_api.get_entities_in_container(outline_root, published=published): + # Convert from generic ContainerEntityListEntry to OutlineRootListEntry for convenience and better type safety: + child_container_version = entry.entity_version.containerversion + if hasattr(child_container_version, "section"): + child_version = child_container_version.section + elif hasattr(child_container_version, "subsection"): + child_version = child_container_version.subsection + elif hasattr(child_container_version, "unit"): + child_version = child_container_version.unit + else: + raise TypeError(f"OutlineRoot {outline_root.pk} had unexpected child {child_container_version}") + children.append(OutlineRootListEntry(child_version=child_version, pinned=entry.pinned)) + return children diff --git a/openedx_learning/apps/authoring/outline_roots/apps.py b/openedx_learning/apps/authoring/outline_roots/apps.py new file mode 100644 index 000000000..4a79d44f8 --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/apps.py @@ -0,0 +1,25 @@ +""" +Outline Roots Django application initialization. +""" + +from django.apps import AppConfig + + +class OutlineRootsConfig(AppConfig): + """ + Configuration for the OutlineRoot Django application. + """ + + name = "openedx_learning.apps.authoring.outline_roots" + verbose_name = "Learning Core > Authoring > Outline Roots" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_outline_roots" + + def ready(self): + """ + Register Section and SectionVersion. + """ + from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel + from .models import OutlineRoot, OutlineRootVersion # pylint: disable=import-outside-toplevel + + register_content_models(OutlineRoot, OutlineRootVersion) diff --git a/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py b/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py new file mode 100644 index 000000000..cfcc7d9b8 --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.19 on 2025-05-13 23:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='OutlineRoot', + fields=[ + ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')), + ], + options={ + 'abstract': False, + }, + bases=('oel_publishing.container',), + ), + migrations.CreateModel( + name='OutlineRootVersion', + fields=[ + ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.containerversion')), + ], + options={ + 'abstract': False, + }, + bases=('oel_publishing.containerversion',), + ), + ] diff --git a/openedx_learning/apps/authoring/outline_roots/migrations/__init__.py b/openedx_learning/apps/authoring/outline_roots/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/outline_roots/models.py b/openedx_learning/apps/authoring/outline_roots/models.py new file mode 100644 index 000000000..5300b8c34 --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/models.py @@ -0,0 +1,59 @@ +""" +Models that implement the "outline root" for each course +""" +from django.db import models + +from ..publishing.models import Container, ContainerVersion + +__all__ = [ + "OutlineRoot", + "OutlineRootVersion", +] + + +class OutlineRoot(Container): + """ + A OutlineRoot is type of Container that defines the root of each course. + + Every course run has one OutlineRoot, and it typically has a list of + Sections that comprise the course, which in turn have Subsections, Units, + and Components. However, we also allow OutlineRoot to have Subsections or + Units as its children, to facilitate smaller courses that don't need a + three-level hierarchy. + + The requirements for OutlineRoot are: + - One OutlineRoot per course run + - Children must all be containers (Sections, Subsections, or Units) and + all children must be the same type + - Never used in libraries + - Never added as a child of another container type + + Via Container and its PublishableEntityMixin, OutlineRoots are publishable + entities. + """ + container = models.OneToOneField( + Container, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + + +class OutlineRootVersion(ContainerVersion): + """ + A OutlineRootVersion is a specific version of a OutlineRoot. + + Via ContainerVersion and its EntityList, it defines the list of + Sections[/Subsections/Units] in this version of the OutlineRoot. + """ + container_version = models.OneToOneField( + ContainerVersion, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + + @property + def outline_root(self): + """ Convenience accessor to the Section this version is associated with """ + return self.container_version.container.outline_root # pylint: disable=no-member 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/projects/dev.py b/projects/dev.py index 41bd7ec58..8f98fbcad 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -34,6 +34,7 @@ "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", + "openedx_learning.apps.authoring.outline_roots.apps.OutlineRootsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_learning.apps.authoring.sections.apps.SectionsConfig", "openedx_learning.apps.authoring.subsections.apps.SubsectionsConfig", diff --git a/test_settings.py b/test_settings.py index e1e4d79ab..88411e7be 100644 --- a/test_settings.py +++ b/test_settings.py @@ -43,6 +43,7 @@ def root(*args): "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", + "openedx_learning.apps.authoring.outline_roots.apps.OutlineRootsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", "openedx_learning.apps.authoring.sections.apps.SectionsConfig", From 28bddc7ca08e1084f6dfece3640baa541d44b348 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 15 May 2025 16:47:13 -0700 Subject: [PATCH 02/12] feat: CatalogCourse + Course[Run] models --- .../apps/authoring/courses/__init__.py | 0 .../apps/authoring/courses/admin.py | 22 +++ .../apps/authoring/courses/api.py | 51 +++++++ .../apps/authoring/courses/apps.py | 15 ++ .../courses/migrations/0001_initial.py | 52 +++++++ .../authoring/courses/migrations/__init__.py | 0 .../apps/authoring/courses/models.py | 144 ++++++++++++++++++ projects/dev.py | 1 + test_settings.py | 1 + 9 files changed, 286 insertions(+) create mode 100644 openedx_learning/apps/authoring/courses/__init__.py create mode 100644 openedx_learning/apps/authoring/courses/admin.py create mode 100644 openedx_learning/apps/authoring/courses/api.py create mode 100644 openedx_learning/apps/authoring/courses/apps.py create mode 100644 openedx_learning/apps/authoring/courses/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/courses/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/courses/models.py diff --git a/openedx_learning/apps/authoring/courses/__init__.py b/openedx_learning/apps/authoring/courses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/courses/admin.py b/openedx_learning/apps/authoring/courses/admin.py new file mode 100644 index 000000000..2158ac0b9 --- /dev/null +++ b/openedx_learning/apps/authoring/courses/admin.py @@ -0,0 +1,22 @@ +""" +Django admin for courses models +""" +from django.contrib import admin + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin + +from .models import CatalogCourse, Course + + +@admin.register(CatalogCourse) +class CatalogCourseAdmin(ReadOnlyModelAdmin): + """ + Django admin for CatalogCourse model + """ + + +@admin.register(Course) +class CourseAdmin(ReadOnlyModelAdmin): + """ + Django admin for Course [Run] model + """ diff --git a/openedx_learning/apps/authoring/courses/api.py b/openedx_learning/apps/authoring/courses/api.py new file mode 100644 index 000000000..c823a9001 --- /dev/null +++ b/openedx_learning/apps/authoring/courses/api.py @@ -0,0 +1,51 @@ +""" +Low Level Courses and Course Runs API + +🛑 UNSTABLE: All APIs related to courses in Learning Core are unstable until +they have parity with modulestore courses. +""" +from __future__ import annotations + +from datetime import datetime +from logging import getLogger + +from .models import Course + +# The public API that will be re-exported by openedx_learning.apps.authoring.api +# is listed in the __all__ entries below. Internal helper functions that are +# private to this module should start with an underscore. If a function does not +# start with an underscore AND it is not in __all__, that function is considered +# to be callable only by other apps in the authoring package. +__all__ = [ + "create_course_and_run", + "create_run", +] + + +log = getLogger() + + +def create_course_and_run( + org_id: str, + course_id: str, + run: str, + *, + learning_package_id: int, + created: datetime, +) -> Course: + """ + Create a new course (CatalogCourse and Course / run). + """ + raise NotImplementedError + + +def create_run( + source_course: Course, + new_run: str, + *, + created: datetime, +) -> Course: + """ + Create a new run of the given course, with the same content. + """ + raise NotImplementedError diff --git a/openedx_learning/apps/authoring/courses/apps.py b/openedx_learning/apps/authoring/courses/apps.py new file mode 100644 index 000000000..f68e08e2c --- /dev/null +++ b/openedx_learning/apps/authoring/courses/apps.py @@ -0,0 +1,15 @@ +""" +Django metadata for the Low Level Courses and Course Runs Django application. +""" +from django.apps import AppConfig + + +class CoursesConfig(AppConfig): + """ + Configuration for the Courses Django application. + """ + + name = "openedx_learning.apps.authoring.courses" + verbose_name = "Learning Core > Authoring > Courses" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_courses" diff --git a/openedx_learning/apps/authoring/courses/migrations/0001_initial.py b/openedx_learning/apps/authoring/courses/migrations/0001_initial.py new file mode 100644 index 000000000..73bcb290d --- /dev/null +++ b/openedx_learning/apps/authoring/courses/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.19 on 2025-05-15 23:46 + +from django.db import migrations, models +import django.db.models.deletion +import openedx_learning.lib.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'), + ('oel_outline_roots', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CatalogCourse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('org_id', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text="The org ID. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be 'MITx'", max_length=100)), + ('course_id', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text="The course ID. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be 'SC1x'", max_length=100)), + ], + options={ + 'verbose_name': 'Catalog Course', + 'verbose_name_plural': 'Catalog Courses', + }, + ), + migrations.CreateModel( + name='Course', + fields=[ + ('run', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text="The course run. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be '1T2025'.", max_length=100)), + ('outline_root', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, primary_key=True, serialize=False, to='oel_outline_roots.outlineroot')), + ('catalog_course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_courses.catalogcourse')), + ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')), + ('source_course', models.ForeignKey(blank=True, help_text='If this course run is a re-run, this field indicates which previous run it was based on.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_courses.course')), + ], + options={ + 'verbose_name': 'Course Run', + 'verbose_name_plural': 'Course Runs', + }, + ), + migrations.AddConstraint( + model_name='catalogcourse', + constraint=models.UniqueConstraint(fields=('org_id', 'course_id'), name='oel_courses_uniq_catalog_course_org_course_id'), + ), + migrations.AddConstraint( + model_name='course', + constraint=models.UniqueConstraint(fields=('catalog_course', 'run'), name='oel_courses_uniq_course_catalog_course_run'), + ), + ] diff --git a/openedx_learning/apps/authoring/courses/migrations/__init__.py b/openedx_learning/apps/authoring/courses/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/courses/models.py b/openedx_learning/apps/authoring/courses/models.py new file mode 100644 index 000000000..c2c15781b --- /dev/null +++ b/openedx_learning/apps/authoring/courses/models.py @@ -0,0 +1,144 @@ +""" +These models form very low-level representations of Courses and Course Runs. + +They don't hold much data on their own, but other apps can attach more useful +data to them. +""" +from __future__ import annotations + +from logging import getLogger + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +from ....lib.fields import case_insensitive_char_field +from ..publishing.models import LearningPackage +from ..outline_roots.models import OutlineRoot + +logger = getLogger() + +__all__ = [ + "CatalogCourse", + "Course", +] + + +class CatalogCourse(models.Model): + """ + A catalog course is a collection of course runs. + + So for example, "Stanford Python 101" is a catalog course, and "Stanford + Python 101 Spring 2025" is a CourseRun of that course. Almost all + interesting use cases are based around the CourseRun - e.g. enrollment + happens in a CourseRun, content is authored in a CourseRun, etc. But + sometimes we need to deal with the related runs of the same course, so this + model exists for those few times we need a reference to all of them. + + A CatalogCourse is not part of a particular learning package, because + although we encourage each course's runs to be in the same learning package, + that's neither a requirement nor always possible. + """ + + # Let's preserve case but avoid having org IDs that differ only in case. + org_id = case_insensitive_char_field( + null=False, + blank=False, + max_length=100, + help_text=_( + "The org ID. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be 'MITx'" + ), + ) + + # Let's preserve case but avoid having course IDs that differ only in case. + course_id = case_insensitive_char_field( + null=False, + blank=False, + max_length=100, + help_text=_( + "The course ID. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be 'SC1x'" + ), + ) + + class Meta: + verbose_name = "Catalog Course" + verbose_name_plural = "Catalog Courses" + constraints = [ + models.UniqueConstraint( + fields=["org_id", "course_id"], + name="oel_courses_uniq_catalog_course_org_course_id", + ), + ] + + + +class Course(models.Model): + """ + A course [run] is a specific instance of a catalog course. + + In general, when we use the term "course" it refers to a Course Run. + + So for example, "Stanford Python 101" is a catalog course, and "Stanford + Python 101 Spring 2025" is a Course Run. + + A Course Run is part of a learning package. Multiple course runs from the + same catalog course can be part of the same learning package so that they + can be more efficient (de-duplicating common data and assets). However, they + are not required to be part of the same learning package, particularly when + imported from legacy course representations. + + A Course Run is also a Learning Context. + + This model is called "Course" instead of "Course Run" for two reasons: + (1) Because 99% of the time we use the term "course" in the code we are + referring to a course run, so this is more consistent; and + (2) Multiple versions of a catalog course may exist for reasons other than + runs; for example, CCX may result in many Course variants of the same + CatalogCourse - these aren't exactly "runs" but may still use separate + instances of this model. TODO: validate this? + + This model is not versioned nor publishable. It also doesn't have much data, + including even the name of the course. All useful data is available via + versioned, related models like CourseMetadata (in edx-platform) or + OutlineRoot. + """ + catalog_course = models.ForeignKey(CatalogCourse, on_delete=models.CASCADE) + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + source_course = models.ForeignKey( + "Course", + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text=_( + "If this course run is a re-run, this field indicates which previous run it was based on." + # This field may have other meanings, e.g. for CCX courses in the future. + ), + ) + + run = case_insensitive_char_field( # Let's preserve case but avoid having run IDs that differ only in case. + null=False, + blank=False, + max_length=100, + help_text=_( + "The course run. For a course with full course key 'course-v1:MITx+SC1x+1T2025', this would be '1T2025'." + ), + ) + + # The outline root defines the content of this course run. + # It's either a list of Sections, a list of Subsections, or a list of Units. + outline_root = models.OneToOneField( + OutlineRoot, + on_delete=models.PROTECT, + primary_key=True, + ) + + class Meta: + verbose_name = "Course Run" + verbose_name_plural = "Course Runs" + constraints = [ + models.UniqueConstraint( + # Regardless of which learning package the run is located in, each [catalog course + run] is unique. + fields=["catalog_course", "run"], + name="oel_courses_uniq_course_catalog_course_run", + ), + ] diff --git a/projects/dev.py b/projects/dev.py index 8f98fbcad..8ba275b8f 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -34,6 +34,7 @@ "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", + "openedx_learning.apps.authoring.courses.apps.CoursesConfig", "openedx_learning.apps.authoring.outline_roots.apps.OutlineRootsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_learning.apps.authoring.sections.apps.SectionsConfig", diff --git a/test_settings.py b/test_settings.py index 88411e7be..a9195e743 100644 --- a/test_settings.py +++ b/test_settings.py @@ -43,6 +43,7 @@ def root(*args): "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", + "openedx_learning.apps.authoring.courses.apps.CoursesConfig", "openedx_learning.apps.authoring.outline_roots.apps.OutlineRootsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", From ed62ce7def27ff2129f6cb85d3b2966eeddf7842 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 16 May 2025 11:59:10 -0700 Subject: [PATCH 03/12] feat: build out the courses API more --- openedx_learning/api/authoring.py | 1 + openedx_learning/api/authoring_models.py | 1 + .../apps/authoring/courses/api.py | 41 ++++++++++++++++++- .../apps/authoring/courses/models.py | 9 ++-- .../apps/authoring/outline_roots/api.py | 12 +++--- .../publishing/models/publishable_entity.py | 4 ++ 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 91adc70ca..99e180e82 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -12,6 +12,7 @@ from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * +from ..apps.authoring.courses.api import * from ..apps.authoring.outline_roots.api import * from ..apps.authoring.publishing.api import * from ..apps.authoring.sections.api import * diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 1e6738867..b3dcc5f9c 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,7 @@ from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * from ..apps.authoring.contents.models import * +from ..apps.authoring.courses.models import * from ..apps.authoring.outline_roots.models import * from ..apps.authoring.publishing.models import * from ..apps.authoring.sections.models import * diff --git a/openedx_learning/apps/authoring/courses/api.py b/openedx_learning/apps/authoring/courses/api.py index c823a9001..6e3f67f36 100644 --- a/openedx_learning/apps/authoring/courses/api.py +++ b/openedx_learning/apps/authoring/courses/api.py @@ -9,7 +9,10 @@ from datetime import datetime from logging import getLogger -from .models import Course +from django.db.transaction import atomic + +from .models import CatalogCourse, Course +from ..outline_roots import api as outline_roots # The public API that will be re-exported by openedx_learning.apps.authoring.api # is listed in the __all__ entries below. Internal helper functions that are @@ -31,12 +34,46 @@ def create_course_and_run( run: str, *, learning_package_id: int, + title: str, created: datetime, + created_by: int | None, + initial_blank_version: bool = True, ) -> Course: """ Create a new course (CatalogCourse and Course / run). + + If initial_blank_version is True (default), the course outline will have an + existing empty version 1, which can be used for building a course from + scratch. For other use cases like importing a course, it could be better to + avoid creating an empty version and jump right to creating an initial + version with the imported content, or even importing the entire version + history. In that case, set initial_blank_version to False. Note that the + provided "title" is ignored in that case. """ - raise NotImplementedError + outline_root_args = { + "learning_package_id": learning_package_id, + "key": f'course-root-v1:{org_id}+{course_id}+{run}', # See docstring of create_outline_root_and_version() + "created": created, + "created_by": created_by, + } + with atomic(savepoint=False): + if initial_blank_version: + outline_root, _version = outline_roots.create_outline_root_and_version(**outline_root_args, title=title) + else: + outline_root = outline_roots.create_outline_root(**outline_root_args) + catalog_course = CatalogCourse.objects.create( + org_id=org_id, + course_id=course_id, + ) + # Create the course run + course = Course.objects.create( + catalog_course=catalog_course, + learning_package_id=learning_package_id, + run=run, + outline_root=outline_root, + source_course=None, + ) + return course def create_run( diff --git a/openedx_learning/apps/authoring/courses/models.py b/openedx_learning/apps/authoring/courses/models.py index c2c15781b..67ab361fe 100644 --- a/openedx_learning/apps/authoring/courses/models.py +++ b/openedx_learning/apps/authoring/courses/models.py @@ -29,9 +29,9 @@ class CatalogCourse(models.Model): A catalog course is a collection of course runs. So for example, "Stanford Python 101" is a catalog course, and "Stanford - Python 101 Spring 2025" is a CourseRun of that course. Almost all - interesting use cases are based around the CourseRun - e.g. enrollment - happens in a CourseRun, content is authored in a CourseRun, etc. But + Python 101 Spring 2025" is a course run of that course. Almost all + interesting use cases are based around the course run - e.g. enrollment + happens in a course run, content is authored in a course run, etc. But sometimes we need to deal with the related runs of the same course, so this model exists for those few times we need a reference to all of them. @@ -100,7 +100,8 @@ class Course(models.Model): This model is not versioned nor publishable. It also doesn't have much data, including even the name of the course. All useful data is available via versioned, related models like CourseMetadata (in edx-platform) or - OutlineRoot. + OutlineRoot. The name/title of the course is stored as the 'title' field of + the OutlineRootVersion.PublishableEntityVersion. """ catalog_course = models.ForeignKey(CatalogCourse, on_delete=models.CASCADE) learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) diff --git a/openedx_learning/apps/authoring/outline_roots/api.py b/openedx_learning/apps/authoring/outline_roots/api.py index a0f8e54b5..72df61777 100644 --- a/openedx_learning/apps/authoring/outline_roots/api.py +++ b/openedx_learning/apps/authoring/outline_roots/api.py @@ -34,15 +34,15 @@ def create_outline_root( *, created: datetime, created_by: int | None, -) -> Section: +) -> OutlineRoot: """ - [ 🛑 UNSTABLE ] Create a new section. + [ 🛑 UNSTABLE ] Create a new OutlineRoot. Args: learning_package_id: The learning package ID. key: The key. created: The creation date. - created_by: The user who created the section. + created_by: The user who created the OutlineRoot. """ return publishing_api.create_container( learning_package_id, @@ -50,7 +50,7 @@ def create_outline_root( created, created_by, can_stand_alone=True, # Not created as part of another container. - container_cls=Section, + container_cls=OutlineRoot, ) @@ -170,7 +170,9 @@ def create_outline_root_and_version( Args: learning_package_id: The learning package ID. - key: The key. + key: The key. We don't really want a "key" for our OutlineRoots, but + we're required to set something here, so by convention this should + be the course ID in the form 'course-root-v1:org+course_id+run'. created: The creation date. created_by: The user who created the section. can_stand_alone: Set to False when created as part of containers 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 From 648d978ed8c9fc4864127b0b3bf8c87b1bd5f7ba Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 16 May 2025 12:00:06 -0700 Subject: [PATCH 04/12] test: minimal test cases for courses --- .../apps/authoring/courses/__init__.py | 0 .../apps/authoring/courses/test_api.py | 75 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/openedx_learning/apps/authoring/courses/__init__.py create mode 100644 tests/openedx_learning/apps/authoring/courses/test_api.py diff --git a/tests/openedx_learning/apps/authoring/courses/__init__.py b/tests/openedx_learning/apps/authoring/courses/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/apps/authoring/courses/test_api.py b/tests/openedx_learning/apps/authoring/courses/test_api.py new file mode 100644 index 000000000..e50939318 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/courses/test_api.py @@ -0,0 +1,75 @@ +""" +Basic tests for the units API. +""" +from datetime import datetime, timezone + +from openedx_learning.api import authoring as authoring_api +from openedx_learning.lib.test_utils import TestCase + +Entry = authoring_api.UnitListEntry + + +class CoursesTestCase(TestCase): + """ Test cases for CatalogCourse + Course """ + + def setUp(self) -> None: + super().setUp() + self.learning_package = authoring_api.create_learning_package( + key="CoursesTestCase", + title="CoursesTestCase", + ) + self.now = datetime(2025, 10, 20, tzinfo=timezone.utc) + + def test_create_course_and_run(self) -> None: + """ + Test creating a Catalog Course and Course Run + (i.e. what users normally think of as "Create a Course") + """ + course = authoring_api.create_course_and_run( + org_id="Org", + course_id="MarineBio", + run="25A", + title="Intro to Marine Biology", + learning_package_id=self.learning_package.id, + created=self.now, + created_by=None, + ) + + assert course.catalog_course.org_id == "Org" + assert course.catalog_course.course_id == "MarineBio" + assert course.run == "25A" + assert course.outline_root.created == self.now + assert course.outline_root.created_by is None + # There is a draft "version 1" of the course, and it's completely empty: + assert course.outline_root.versioning.draft.title == "Intro to Marine Biology" + assert course.outline_root.versioning.draft.version_num == 1 + assert course.outline_root.versioning.draft.created == self.now + assert course.outline_root.versioning.draft.created_by is None + assert not authoring_api.get_entities_in_container(course.outline_root, published=False) + # There is no published version of the course: + assert course.outline_root.versioning.published is None + + def test_create_empty_course_and_run(self) -> None: + """ + Test creating a Catalog Course and Course Run but without any initial + version (this would be done e.g. at the start of an import workflow) + """ + course = authoring_api.create_course_and_run( + org_id="Org", + course_id="MarineBio", + run="25A", + title="", # title is ignored when initial_blank_version=False + learning_package_id=self.learning_package.id, + created=self.now, + created_by=None, + initial_blank_version=False, + ) + + assert course.catalog_course.org_id == "Org" + assert course.catalog_course.course_id == "MarineBio" + assert course.run == "25A" + assert course.outline_root.created == self.now + assert course.outline_root.created_by is None + # There is no "version 1" of the course: + assert course.outline_root.versioning.draft is None + assert course.outline_root.versioning.published is None From 4a662a26eb83656e46b8f44fbe0b07e84711723a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Jun 2025 10:48:13 -0700 Subject: [PATCH 05/12] feat: relax requirement that all OutlineRoot children are the same --- .../apps/authoring/outline_roots/api.py | 17 ++++++----------- .../apps/authoring/outline_roots/models.py | 2 -- .../apps/authoring/courses/test_api.py | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/openedx_learning/apps/authoring/outline_roots/api.py b/openedx_learning/apps/authoring/outline_roots/api.py index 72df61777..e5030523b 100644 --- a/openedx_learning/apps/authoring/outline_roots/api.py +++ b/openedx_learning/apps/authoring/outline_roots/api.py @@ -12,6 +12,7 @@ from openedx_learning.apps.authoring.units.models import Unit, UnitVersion from ..publishing import api as publishing_api +from ..publishing.models import Container, ContainerVersion from .models import OutlineRoot, OutlineRootVersion # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured @@ -90,7 +91,7 @@ def create_outline_root_version( def _make_entity_rows( - children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None, + children: list[Section | SectionVersion | Subsection | SubsectionVersion | Unit | UnitVersion] | None, ) -> list[publishing_api.ContainerEntityRow] | None: """ Helper method: given a list of children for the outline root, return the @@ -103,12 +104,6 @@ def _make_entity_rows( if children is None: # When these are None, that means don't change the entities in the list. return None - if not ( - all(isinstance(c, (Section, SectionVersion)) for c in children) or - all(isinstance(c, (Subsection, SubsectionVersion)) for c in children) or - all(isinstance(c, (Unit, UnitVersion)) for c in children) - ): - raise TypeError("OutlineRoot children must be Section[Version], Subsection[Version], or Unit[Version] objects.") return [ ( publishing_api.ContainerEntityRow( @@ -128,7 +123,7 @@ def create_next_outline_root_version( outline_root: OutlineRoot, *, title: str | None = None, - children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None = None, # pylint: disable=line-too-long # noqa: E501 + children: list[Section | SectionVersion | Subsection | SubsectionVersion | Unit | UnitVersion] | None = None, created: datetime, created_by: int | None = None, entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE, @@ -161,7 +156,7 @@ def create_outline_root_and_version( key: str, *, title: str, - children: list[Section | SectionVersion] | list[Subsection | SubsectionVersion] | list[Unit | UnitVersion] | None = None, # pylint: disable=line-too-long # noqa: E501 + children: list[Section | SectionVersion | Subsection | SubsectionVersion | Unit | UnitVersion] | None = None, created: datetime, created_by: int | None = None, ) -> tuple[OutlineRoot, OutlineRootVersion]: @@ -226,11 +221,11 @@ class OutlineRootListEntry: pinned: bool = False @property - def container(self): + def container(self) -> Container: return self.container_version.container @property - def container_version(self): + def container_version(self) -> ContainerVersion: return self.child_version.container_version diff --git a/openedx_learning/apps/authoring/outline_roots/models.py b/openedx_learning/apps/authoring/outline_roots/models.py index 5300b8c34..cd0bac12f 100644 --- a/openedx_learning/apps/authoring/outline_roots/models.py +++ b/openedx_learning/apps/authoring/outline_roots/models.py @@ -23,8 +23,6 @@ class OutlineRoot(Container): The requirements for OutlineRoot are: - One OutlineRoot per course run - - Children must all be containers (Sections, Subsections, or Units) and - all children must be the same type - Never used in libraries - Never added as a child of another container type diff --git a/tests/openedx_learning/apps/authoring/courses/test_api.py b/tests/openedx_learning/apps/authoring/courses/test_api.py index e50939318..108f98815 100644 --- a/tests/openedx_learning/apps/authoring/courses/test_api.py +++ b/tests/openedx_learning/apps/authoring/courses/test_api.py @@ -1,5 +1,5 @@ """ -Basic tests for the units API. +Basic tests for the courses API. """ from datetime import datetime, timezone From 6d793f30e33bdb9d24954221a43efe04f3444883 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Jun 2025 11:07:57 -0700 Subject: [PATCH 06/12] fix: quality/typing issue (work around mypy limitation) See https://github.com/python/mypy/issues/5382 --- openedx_learning/apps/authoring/courses/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx_learning/apps/authoring/courses/api.py b/openedx_learning/apps/authoring/courses/api.py index 6e3f67f36..9aad40e6e 100644 --- a/openedx_learning/apps/authoring/courses/api.py +++ b/openedx_learning/apps/authoring/courses/api.py @@ -8,6 +8,7 @@ from datetime import datetime from logging import getLogger +from typing import Any from django.db.transaction import atomic @@ -50,7 +51,7 @@ def create_course_and_run( history. In that case, set initial_blank_version to False. Note that the provided "title" is ignored in that case. """ - outline_root_args = { + outline_root_args: dict[str, Any] = { "learning_package_id": learning_package_id, "key": f'course-root-v1:{org_id}+{course_id}+{run}', # See docstring of create_outline_root_and_version() "created": created, From cf4a19f6d5dab3e689b47ff6d1cb2d04362f63ab Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Jun 2025 11:26:12 -0700 Subject: [PATCH 07/12] chore: minor quality fix --- openedx_learning/apps/authoring/courses/api.py | 4 ++-- .../apps/authoring/courses/migrations/0001_initial.py | 3 ++- openedx_learning/apps/authoring/courses/models.py | 6 ++---- .../apps/authoring/outline_roots/migrations/0001_initial.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openedx_learning/apps/authoring/courses/api.py b/openedx_learning/apps/authoring/courses/api.py index 9aad40e6e..1ed9921ef 100644 --- a/openedx_learning/apps/authoring/courses/api.py +++ b/openedx_learning/apps/authoring/courses/api.py @@ -12,8 +12,8 @@ from django.db.transaction import atomic -from .models import CatalogCourse, Course from ..outline_roots import api as outline_roots +from .models import CatalogCourse, Course # The public API that will be re-exported by openedx_learning.apps.authoring.api # is listed in the __all__ entries below. Internal helper functions that are @@ -42,7 +42,7 @@ def create_course_and_run( ) -> Course: """ Create a new course (CatalogCourse and Course / run). - + If initial_blank_version is True (default), the course outline will have an existing empty version 1, which can be used for building a course from scratch. For other use cases like importing a course, it could be better to diff --git a/openedx_learning/apps/authoring/courses/migrations/0001_initial.py b/openedx_learning/apps/authoring/courses/migrations/0001_initial.py index 73bcb290d..c87d6772f 100644 --- a/openedx_learning/apps/authoring/courses/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/courses/migrations/0001_initial.py @@ -1,7 +1,8 @@ # Generated by Django 4.2.19 on 2025-05-15 23:46 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + import openedx_learning.lib.fields diff --git a/openedx_learning/apps/authoring/courses/models.py b/openedx_learning/apps/authoring/courses/models.py index 67ab361fe..13070daa4 100644 --- a/openedx_learning/apps/authoring/courses/models.py +++ b/openedx_learning/apps/authoring/courses/models.py @@ -11,10 +11,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ - from ....lib.fields import case_insensitive_char_field -from ..publishing.models import LearningPackage from ..outline_roots.models import OutlineRoot +from ..publishing.models import LearningPackage logger = getLogger() @@ -71,7 +70,6 @@ class Meta: ] - class Course(models.Model): """ A course [run] is a specific instance of a catalog course. @@ -96,7 +94,7 @@ class Course(models.Model): runs; for example, CCX may result in many Course variants of the same CatalogCourse - these aren't exactly "runs" but may still use separate instances of this model. TODO: validate this? - + This model is not versioned nor publishable. It also doesn't have much data, including even the name of the course. All useful data is available via versioned, related models like CourseMetadata (in edx-platform) or diff --git a/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py b/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py index cfcc7d9b8..a5f396df6 100644 --- a/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/outline_roots/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.19 on 2025-05-13 23:16 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): From 7e043822c5ec31ec85da58265046420106aa2115 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Fri, 13 Jun 2025 21:40:27 -0400 Subject: [PATCH 08/12] feat: Basic Container admin; still needs entity inline --- .../apps/authoring/publishing/admin.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index a710536c8..b1812e3fc 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -5,6 +5,8 @@ from django.contrib import admin from django.db.models import Count +from django.urls import reverse +from django.utils.html import format_html from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html @@ -15,6 +17,8 @@ PublishableEntity, PublishLog, PublishLogRecord, + Container, + ContainerVersion, ) from .models.publish_log import Published @@ -246,3 +250,86 @@ def get_queryset(self, request): queryset = super().get_queryset(request) return queryset.select_related("learning_package", "changed_by") \ .annotate(num_changes=Count("records")) + + +class ContainerVersionInline(admin.TabularInline): + """ + Inline admin view of ContainerVersions from the Container Admin + """ + model = ContainerVersion + fields = ["version_num", "created", "title", "format_uuid"] + readonly_fields = ["version_num", "created", "title", "uuid", "format_uuid"] + extra = 0 + + @admin.display(description="UUID") + def format_uuid(self, cv_obj): + return format_html( + '{}', + reverse("admin:oel_publishing_containerversion_change", args=(cv_obj.pk,)), + cv_obj.uuid, + ) + + def see_also(self, entity): + return one_to_one_related_model_html(entity) + + +@admin.register(Container) +class ContainerAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for Container + """ + list_display = ("key", "uuid", "created") + readonly_fields = [ + "learning_package", + "uuid", + "key", + "created", + ] + search_fields = ["publishable_entity__uuid", "publishable_entity__key"] + inlines = [ContainerVersionInline] + + def learning_package(self, obj: Container) -> LearningPackage: + return obj.publishable_entity.learning_package + + def see_also(self, entity): + return one_to_one_related_model_html(entity) + + +# @@TODO +# class EntityListInline(admin.TabularInline): +# """ +# Inline admin view of entities from the Container admin +# """ + + +@admin.register(ContainerVersion) +class ContainerVersionAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for ContaienrVersion + """ + readonly_fields = [ + "container", + "uuid", + "title", + "version_num", + "created", + "entity_list" + ] + fields = [ + "container", + "uuid", + "title", + "version_num", + "created", + "entity_list" + ] + list_display = ["container", "version_num", "uuid", "created"] + # inlines = [EntityListInline] @@TODO + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related( + "container", + "container__publishable_entity", + "publishable_entity_version", + ) From c1c0b4de05ce8ae37e2453b2515a5560ef2a30be Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 16 Jun 2025 13:35:06 -0400 Subject: [PATCH 09/12] feat: Add EntityList and ContainerVersion admin inlines --- .../apps/authoring/publishing/admin.py | 269 ++++++++++++++---- openedx_learning/lib/admin_utils.py | 18 +- 2 files changed, 229 insertions(+), 58 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index b1812e3fc..7e587e465 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -3,16 +3,23 @@ """ from __future__ import annotations +import functools + from django.contrib import admin from django.db.models import Count -from django.urls import reverse 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 ( DraftChangeLog, DraftChangeLogRecord, + EntityList, + EntityListRow, LearningPackage, PublishableEntity, PublishLog, @@ -126,6 +133,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( @@ -135,16 +148,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): @@ -252,25 +255,37 @@ def get_queryset(self, request): .annotate(num_changes=Count("records")) -class ContainerVersionInline(admin.TabularInline): +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 from the Container Admin + Inline admin view of ContainerVersions in a given Container """ model = ContainerVersion - fields = ["version_num", "created", "title", "format_uuid"] - readonly_fields = ["version_num", "created", "title", "uuid", "format_uuid"] + ordering = ["-publishable_entity_version__version_num"] + fields = [ + "uuid", + "version_num", + "title", + "children", + "created", + "created_by", + ] + readonly_fields = fields # type: ignore[assignment] extra = 0 - @admin.display(description="UUID") - def format_uuid(self, cv_obj): - return format_html( - '{}', - reverse("admin:oel_publishing_containerversion_change", args=(cv_obj.pk,)), - cv_obj.uuid, + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "publishable_entity_version" ) - def see_also(self, entity): - return one_to_one_related_model_html(entity) + def children(self, obj: ContainerVersion): + return _entity_list_detail_link(obj.entity_list) @admin.register(Container) @@ -278,58 +293,198 @@ class ContainerAdmin(ReadOnlyModelAdmin): """ Django admin configuration for Container """ - list_display = ("key", "uuid", "created") - readonly_fields = [ + list_display = ("key", "uuid", "created", "see_also") + fields = [ + "publishable_entity", "learning_package", - "uuid", - "key", + "draft_version", + "published_version", "created", + "created_by" ] + readonly_fields = fields # type: ignore[assignment] search_fields = ["publishable_entity__uuid", "publishable_entity__key"] - inlines = [ContainerVersionInline] + inlines = [ContainerVersionInlineForContainer] - def learning_package(self, obj: Container) -> LearningPackage: - return obj.publishable_entity.learning_package + def uuid(self, obj: Container) -> SafeText: + return model_detail_link(obj, obj.uuid) - def see_also(self, entity): - return one_to_one_related_model_html(entity) + 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_version(self, entity: PublishableEntity): + return entity.draft.version.version_num if entity.draft.version else None -# @@TODO -# class EntityListInline(admin.TabularInline): -# """ -# Inline admin view of entities from the Container admin -# """ + def published_version(self, entity: PublishableEntity): + return entity.published.version.version_num if entity.published and entity.published.version else None + + def see_also(self, entity: PublishableEntity): + return one_to_one_related_model_html(entity) -@admin.register(ContainerVersion) -class ContainerVersionAdmin(ReadOnlyModelAdmin): +class ContainerVersionInlineForEntityList(admin.TabularInline): """ - Django admin configuration for ContaienrVersion + Inline admin view of ContainerVersions which use a given EntityList """ - readonly_fields = [ - "container", - "uuid", - "title", - "version_num", - "created", - "entity_list" - ] + 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 = [ - "container", "uuid", - "title", "version_num", + "container_key", + "title", "created", - "entity_list" + "created_by", ] - list_display = ["container", "version_num", "uuid", "created"] - # inlines = [EntityListInline] @@TODO + readonly_fields = fields # type: ignore[assignment] + extra = 0 def get_queryset(self, request): - queryset = super().get_queryset(request) - return queryset.select_related( + 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 + """ + verbose_name = "Row" + model = EntityListRow + readonly_fields = [ + "order_num", + "entity_and_connected_models", + "pinned_version_num", + "connected_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 entity_and_connected_models(self, obj: EntityListRow): + return format_html( + "{}
    {}
", + model_detail_link(obj.entity, obj.entity.key), + one_to_one_related_model_html(obj.entity), + ) + + def pinned_version_num(self, obj: EntityListRow): + return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)" + + def connected_container_models(self, obj: EntityListRow) -> SafeText: + if not hasattr(obj.entity, "container"): + return SafeText("(Not a Container)") + return 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 = [EntityListRowInline, ContainerVersionInlineForEntityList] + + 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. + """ + versions = obj.container_versions.order_by("-pk") + return versions.first() if versions else None diff --git a/openedx_learning/lib/admin_utils.py b/openedx_learning/lib/admin_utils.py index ece0e3a45..6a1e15fbd 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.lower()}_change", + args=(obj.pk,), + ), + link_text, + ) From b14c00ef2c3ff8077d8180b2c471ee214d54063f Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 16 Jun 2025 15:53:24 -0400 Subject: [PATCH 10/12] feat: Improvements to admin pages for Container, EL, and container types --- .../apps/authoring/publishing/admin.py | 54 ++++++++++++------- openedx_learning/lib/admin_utils.py | 2 +- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index 7e587e465..a90c79852 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -293,14 +293,16 @@ class ContainerAdmin(ReadOnlyModelAdmin): """ Django admin configuration for Container """ - list_display = ("key", "uuid", "created", "see_also") + list_display = ("key", "uuid", "created", "draft", "published", "see_also") fields = [ "publishable_entity", "learning_package", - "draft_version", - "published_version", + "draft", + "published", "created", - "created_by" + "created_by", + "see_also", + "most_recent_parent_entity_list", ] readonly_fields = fields # type: ignore[assignment] search_fields = ["publishable_entity__uuid", "publishable_entity__key"] @@ -323,14 +325,23 @@ def get_queryset(self, request): "publishable_entity__draft__version", ) - def draft_version(self, entity: PublishableEntity): - return entity.draft.version.version_num if entity.draft.version else None + def draft(self, obj: Container) -> SafeText: + if draft := obj.versioning.draft: + return format_html("Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list)) + return SafeText("-") - def published_version(self, entity: PublishableEntity): - return entity.published.version.version_num if entity.published and entity.published.version else None + def published(self, obj: Container) -> SafeText: + 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, entity: PublishableEntity): - return one_to_one_related_model_html(entity) + 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): @@ -367,13 +378,12 @@ class EntityListRowInline(admin.TabularInline): """ Table of entity rows in the entitylist admin """ - verbose_name = "Row" model = EntityListRow readonly_fields = [ "order_num", - "entity_and_connected_models", "pinned_version_num", - "connected_container_models", + "entity_models", + "container_models", "container_children", ] fields = readonly_fields # type: ignore[assignment] @@ -384,20 +394,24 @@ def get_queryset(self, request): "entity_version", ) - def entity_and_connected_models(self, obj: EntityListRow): + 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 pinned_version_num(self, obj: EntityListRow): - return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)" - - def connected_container_models(self, obj: EntityListRow) -> SafeText: + def container_models(self, obj: EntityListRow) -> SafeText: if not hasattr(obj.entity, "container"): return SafeText("(Not a Container)") - return one_to_one_related_model_html(obj.entity.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: """ @@ -428,7 +442,7 @@ class EntityListAdmin(ReadOnlyModelAdmin): "recent_container", "recent_container_package" ] - inlines = [EntityListRowInline, ContainerVersionInlineForEntityList] + inlines = [ContainerVersionInlineForEntityList, EntityListRowInline] def entity_list(self, obj: EntityList) -> SafeText: return model_detail_link(obj, f"EntityList #{obj.pk}") diff --git a/openedx_learning/lib/admin_utils.py b/openedx_learning/lib/admin_utils.py index 6a1e15fbd..ae7602d61 100644 --- a/openedx_learning/lib/admin_utils.py +++ b/openedx_learning/lib/admin_utils.py @@ -109,7 +109,7 @@ def model_detail_link(obj: models.Model, link_text: str) -> SafeText: return format_html( '{}', reverse( - f"admin:{obj._meta.app_label}_{obj._meta.model_name.lower()}_change", + f"admin:{obj._meta.app_label}_{(obj._meta.model_name or obj.__class__.__name__).lower()}_change", args=(obj.pk,), ), link_text, From 4061a4b578b8341ae6a7f4fbd410e1be492509dc Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 16 Jun 2025 16:27:13 -0400 Subject: [PATCH 11/12] feat: Add very simple admin pages for Unit ... OutlineRoot --- .../apps/authoring/outline_roots/admin.py | 28 +++++++++++++++++++ .../apps/authoring/sections/admin.py | 28 +++++++++++++++++++ .../apps/authoring/subsections/admin.py | 28 +++++++++++++++++++ .../apps/authoring/units/admin.py | 28 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 openedx_learning/apps/authoring/outline_roots/admin.py 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/apps/authoring/outline_roots/admin.py b/openedx_learning/apps/authoring/outline_roots/admin.py new file mode 100644 index 000000000..a2bb310f7 --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/admin.py @@ -0,0 +1,28 @@ +""" +Django admin for outline roots 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 OutlineRoot + + +@admin.register(OutlineRoot) +class OutlineRootAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["outline_root_id", "key"] + fields = ["see"] + readonly_fields = ["see"] + + def outline_root_id(self, obj: OutlineRoot) -> int: + return obj.pk + + def key(self, obj: OutlineRoot) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: OutlineRoot) -> SafeText: + return self.key(obj) \ No newline at end of file diff --git a/openedx_learning/apps/authoring/sections/admin.py b/openedx_learning/apps/authoring/sections/admin.py new file mode 100644 index 000000000..1e61a7d2b --- /dev/null +++ b/openedx_learning/apps/authoring/sections/admin.py @@ -0,0 +1,28 @@ +""" +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 + + +@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"] + + 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) \ No newline at end of file diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py new file mode 100644 index 000000000..682a139a8 --- /dev/null +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -0,0 +1,28 @@ +""" +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 + + +@admin.register(Subsection) +class SubsectionAdmin(ReadOnlyModelAdmin): + """ + Django admin for Subsection model + """ + list_display = ["subsection_id", "key"] + fields = ["see"] + readonly_fields = ["see"] + + 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..fd2b2df4e --- /dev/null +++ b/openedx_learning/apps/authoring/units/admin.py @@ -0,0 +1,28 @@ +""" +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 + + +@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", "key"] + fields = ["see"] + readonly_fields = ["see"] + + def unit_id(self, obj: Unit) -> int: + return obj.pk + + def key(self, obj: Unit) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: Unit) -> SafeText: + return self.key(obj) \ No newline at end of file From 94956ed90e45a9fda5bdab028f440642583372d3 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Sat, 21 Jun 2025 23:47:14 -0400 Subject: [PATCH 12/12] feat: tabular version inline for unit admin --- .../apps/authoring/subsections/admin.py | 2 +- openedx_learning/apps/authoring/units/admin.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py index 682a139a8..424557f7b 100644 --- a/openedx_learning/apps/authoring/subsections/admin.py +++ b/openedx_learning/apps/authoring/subsections/admin.py @@ -12,7 +12,7 @@ @admin.register(Subsection) class SubsectionAdmin(ReadOnlyModelAdmin): """ - Django admin for Subsection model + Very minimal interface... just direct the admin user's attention towards the related Container model admin. """ list_display = ["subsection_id", "key"] fields = ["see"] diff --git a/openedx_learning/apps/authoring/units/admin.py b/openedx_learning/apps/authoring/units/admin.py index fd2b2df4e..25eecaf49 100644 --- a/openedx_learning/apps/authoring/units/admin.py +++ b/openedx_learning/apps/authoring/units/admin.py @@ -6,7 +6,16 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link -from .models import Unit +from .models import Unit, UnitVersion + + +class UnitVersionInline(admin.TabularInline): + """ + Minimal table for unit versions in a unit + + @@TODO add inlines to the other container version types (subsections, sections, etc) + """ + model = UnitVersion @admin.register(Unit) @@ -14,15 +23,16 @@ class UnitAdmin(ReadOnlyModelAdmin): """ Very minimal interface... just direct the admin user's attention towards the related Container model admin. """ - list_display = ["unit_id", "key"] + list_display = ["unit_id", "container_key"] fields = ["see"] readonly_fields = ["see"] + inlines = [UnitVersionInline] def unit_id(self, obj: Unit) -> int: return obj.pk - def key(self, obj: Unit) -> SafeText: + def container_key(self, obj: Unit) -> SafeText: return model_detail_link(obj.container, obj.container.key) def see(self, obj: Unit) -> SafeText: - return self.key(obj) \ No newline at end of file + return self.container_key(obj) \ No newline at end of file