From 7dfcc1e2f4d27221aedce5d86ef12343080b8ed8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 13 May 2025 16:17:30 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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):