diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 5db14ceff..99e180e82 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -12,6 +12,8 @@ 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 * from ..apps.authoring.subsections.api import * diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 617d85dc4..b3dcc5f9c 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,8 @@ 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 * from ..apps.authoring.subsections.models import * 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..1ed9921ef --- /dev/null +++ b/openedx_learning/apps/authoring/courses/api.py @@ -0,0 +1,89 @@ +""" +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 typing import Any + +from django.db.transaction import atomic + +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 +# 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, + 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. + """ + 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, + "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( + 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..c87d6772f --- /dev/null +++ b/openedx_learning/apps/authoring/courses/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.19 on 2025-05-15 23:46 + +import django.db.models.deletion +from django.db import migrations, models + +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..13070daa4 --- /dev/null +++ b/openedx_learning/apps/authoring/courses/models.py @@ -0,0 +1,143 @@ +""" +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 ..outline_roots.models import OutlineRoot +from ..publishing.models import LearningPackage + +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 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. + + 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. 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) + 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/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/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/outline_roots/api.py b/openedx_learning/apps/authoring/outline_roots/api.py new file mode 100644 index 000000000..e5030523b --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/api.py @@ -0,0 +1,261 @@ +"""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 ..publishing.models import Container, ContainerVersion +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, +) -> OutlineRoot: + """ + [ 🛑 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 OutlineRoot. + """ + 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=OutlineRoot, + ) + + +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 | Subsection | SubsectionVersion | 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 + 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 | Subsection | SubsectionVersion | Unit | UnitVersion] | None = None, + 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 | Subsection | SubsectionVersion | Unit | UnitVersion] | None = None, + 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. 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 + """ + 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) -> Container: + return self.container_version.container + + @property + def container_version(self) -> ContainerVersion: + 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..a5f396df6 --- /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 + +import django.db.models.deletion +from django.db import migrations, models + + +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..cd0bac12f --- /dev/null +++ b/openedx_learning/apps/authoring/outline_roots/models.py @@ -0,0 +1,57 @@ +""" +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 + - 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/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index a710536c8..a90c79852 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -3,18 +3,29 @@ """ 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 ( DraftChangeLog, DraftChangeLogRecord, + EntityList, + EntityListRow, LearningPackage, PublishableEntity, PublishLog, PublishLogRecord, + Container, + ContainerVersion, ) from .models.publish_log import Published @@ -122,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( @@ -131,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): @@ -246,3 +253,252 @@ 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: + if draft := obj.versioning.draft: + return format_html("Version {} ({})", draft.version_num, _entity_list_detail_link(draft.entity_list)) + return SafeText("-") + + def published(self, obj: Container) -> SafeText: + if published := obj.versioning.published: + return format_html("Version {} ({})", published.version_num, _entity_list_detail_link(published.entity_list)) + return SafeText("-") + + def see_also(self, obj: Container): + return one_to_one_related_model_html(obj) + + def most_recent_parent_entity_list(self, obj: Container) -> SafeText: + if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first(): + return _entity_list_detail_link(latest_row.entity_list) + return SafeText("-") + + +class ContainerVersionInlineForEntityList(admin.TabularInline): + """ + Inline admin view of ContainerVersions which use a given EntityList + """ + model = ContainerVersion + verbose_name = "Container Version that references this Entity List" + verbose_name_plural = "Container Versions that reference this Entity List" + ordering = ["-pk"] # Newest first + fields = [ + "uuid", + "version_num", + "container_key", + "title", + "created", + "created_by", + ] + readonly_fields = fields # type: ignore[assignment] + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "container", + "container__publishable_entity", + "publishable_entity_version", + ) + + def container_key(self, obj: ContainerVersion) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + +class EntityListRowInline(admin.TabularInline): + """ + Table of entity rows in the entitylist admin + """ + model = EntityListRow + readonly_fields = [ + "order_num", + "pinned_version_num", + "entity_models", + "container_models", + "container_children", + ] + fields = readonly_fields # type: ignore[assignment] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "entity", + "entity_version", + ) + + def pinned_version_num(self, obj: EntityListRow): + return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)" + + def entity_models(self, obj: EntityListRow): + return format_html( + "{}", + model_detail_link(obj.entity, obj.entity.key), + one_to_one_related_model_html(obj.entity), + ) + + def container_models(self, obj: EntityListRow) -> SafeText: + if not hasattr(obj.entity, "container"): + return SafeText("(Not a Container)") + return format_html( + "{}", + model_detail_link(obj.entity.container, str(obj.entity.container)), + one_to_one_related_model_html(obj.entity.container), + ) + + def container_children(self, obj: EntityListRow) -> SafeText: + """ + If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing. + + When determining which ContainerVersion to grab the EntityList from, prefer the pinned + version if there is one; otherwise use the Draft version. + """ + if not hasattr(obj.entity, "container"): + return SafeText("(Not a Container)") + child_container_version: ContainerVersion = ( + obj.entity_version.containerversion + if obj.entity_version + else obj.entity.container.versioning.draft + ) + return _entity_list_detail_link(child_container_version.entity_list) + + +@admin.register(EntityList) +class EntityListAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for EntityList + """ + list_display = [ + "entity_list", + "row_count", + "recent_container_version_num", + "recent_container", + "recent_container_package" + ] + inlines = [ContainerVersionInlineForEntityList, EntityListRowInline] + + def entity_list(self, obj: EntityList) -> SafeText: + return model_detail_link(obj, f"EntityList #{obj.pk}") + + def row_count(self, obj: EntityList) -> int: + return obj.entitylistrow_set.count() + + def recent_container_version_num(self, obj: EntityList) -> str: + """ + Number of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return f"Version {latest.version_num}" + else: + return "-" + + def recent_container(self, obj: EntityList) -> SafeText | None: + """ + Link to the Container of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return format_html("of: {}", model_detail_link(latest.container, latest.container.key)) + else: + return None + + def recent_container_package(self, obj: EntityList) -> SafeText | None: + """ + Link to the LearningPackage of the newest ContainerVersion that references this EntityList + """ + if latest := _latest_container_version(obj): + return format_html( + "in: {}", + model_detail_link( + latest.container.publishable_entity.learning_package, + latest.container.publishable_entity.learning_package.key + ) + ) + else: + return None + + # We'd like it to appear as if these three columns are just a single + # nicely-formatted column, so only give the left one a description. + recent_container_version_num.short_description = ( # type: ignore[attr-defined] + "Most recent container version using this entity list" + ) + recent_container.short_description = "" # type: ignore[attr-defined] + recent_container_package.short_description = "" # type: ignore[attr-defined] + + +@functools.cache +def _latest_container_version(obj: EntityList) -> ContainerVersion | None: + """ + Any given EntityList can be used by multiple ContainerVersion (which may even + span multiple Containers). We only have space here to show one ContainerVersion + easily, so let's show the one that's most likely to be interesting to the Django + admin user: the most-recently-created one. + """ + versions = obj.container_versions.order_by("-pk") + return versions.first() if versions else None diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py index 9a53f22cb..60c5fe1a9 100644 --- a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -570,6 +570,10 @@ def title(self) -> str: def created(self) -> datetime: return self.publishable_entity_version.created + @property + def created_by(self): + return self.publishable_entity_version.created_by + @property def version_num(self) -> int: return self.publishable_entity_version.version_num diff --git a/openedx_learning/apps/authoring/sections/admin.py b/openedx_learning/apps/authoring/sections/admin.py new file mode 100644 index 000000000..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/sections/apps.py b/openedx_learning/apps/authoring/sections/apps.py index 64bc5f871..42c8d6ec9 100644 --- a/openedx_learning/apps/authoring/sections/apps.py +++ b/openedx_learning/apps/authoring/sections/apps.py @@ -1,5 +1,5 @@ """ -Subsection Django application initialization. +Sections Django application initialization. """ from django.apps import AppConfig @@ -7,7 +7,7 @@ class SectionsConfig(AppConfig): """ - Configuration for the subsections Django application. + Configuration for the Sections Django application. """ name = "openedx_learning.apps.authoring.sections" diff --git a/openedx_learning/apps/authoring/subsections/admin.py b/openedx_learning/apps/authoring/subsections/admin.py new file mode 100644 index 000000000..424557f7b --- /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): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["subsection_id", "key"] + fields = ["see"] + readonly_fields = ["see"] + + 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..25eecaf49 --- /dev/null +++ b/openedx_learning/apps/authoring/units/admin.py @@ -0,0 +1,38 @@ +""" +Django admin for units models +""" +from django.contrib import admin +from django.utils.safestring import SafeText + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, model_detail_link + +from .models import Unit, UnitVersion + + +class UnitVersionInline(admin.TabularInline): + """ + Minimal table for unit versions in a unit + + @@TODO add inlines to the other container version types (subsections, sections, etc) + """ + model = UnitVersion + + +@admin.register(Unit) +class UnitAdmin(ReadOnlyModelAdmin): + """ + Very minimal interface... just direct the admin user's attention towards the related Container model admin. + """ + list_display = ["unit_id", "container_key"] + fields = ["see"] + readonly_fields = ["see"] + inlines = [UnitVersionInline] + + def unit_id(self, obj: Unit) -> int: + return obj.pk + + def container_key(self, obj: Unit) -> SafeText: + return model_detail_link(obj.container, obj.container.key) + + def see(self, obj: Unit) -> SafeText: + return self.container_key(obj) \ No newline at end of file diff --git a/openedx_learning/lib/admin_utils.py b/openedx_learning/lib/admin_utils.py index ece0e3a45..ae7602d61 100644 --- a/openedx_learning/lib/admin_utils.py +++ b/openedx_learning/lib/admin_utils.py @@ -2,9 +2,11 @@ Convenience utilities for the Django Admin. """ from django.contrib import admin +from django.db import models from django.db.models.fields.reverse_related import OneToOneRel from django.urls import NoReverseMatch, reverse from django.utils.html import format_html, format_html_join +from django.utils.safestring import SafeText class ReadOnlyModelAdmin(admin.ModelAdmin): @@ -31,7 +33,7 @@ def has_delete_permission(self, request, obj=None): return False -def one_to_one_related_model_html(model_obj): +def one_to_one_related_model_html(model_obj: models.Model) -> SafeText: """ HTML for clickable list of a models that are 1:1-related to ``model_obj``. @@ -98,3 +100,17 @@ def one_to_one_related_model_html(model_obj): text.append(html) return format_html_join("\n", "
  • {}
  • ", ((t,) for t in text)) + + +def model_detail_link(obj: models.Model, link_text: str) -> SafeText: + """ + Render an HTML link to the admin focus page for `obj`. + """ + return format_html( + '{}', + reverse( + f"admin:{obj._meta.app_label}_{(obj._meta.model_name or obj.__class__.__name__).lower()}_change", + args=(obj.pk,), + ), + link_text, + ) diff --git a/projects/dev.py b/projects/dev.py index 41bd7ec58..8ba275b8f 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -34,6 +34,8 @@ "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", "openedx_learning.apps.authoring.subsections.apps.SubsectionsConfig", diff --git a/test_settings.py b/test_settings.py index e1e4d79ab..a9195e743 100644 --- a/test_settings.py +++ b/test_settings.py @@ -43,6 +43,8 @@ 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", "openedx_learning.apps.authoring.sections.apps.SectionsConfig", 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..108f98815 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/courses/test_api.py @@ -0,0 +1,75 @@ +""" +Basic tests for the courses 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