diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py index 124fc2cbc216..459c922e6636 100644 --- a/cms/djangoapps/modulestore_migrator/tasks.py +++ b/cms/djangoapps/modulestore_migrator/tasks.py @@ -26,8 +26,6 @@ from openedx_learning.api.authoring_models import ( Collection, Component, - Course, - CatalogCourse, LearningPackage, PublishableEntity, PublishableEntityVersion, @@ -35,6 +33,10 @@ from user_tasks.tasks import UserTask, UserTaskStatus from xblock.core import XBlock +from openedx.core.djangoapps.content.courses.models import ( + Course, + CatalogCourse, +) from openedx.core.djangoapps.content_libraries.api import ContainerType from openedx.core.djangoapps.content_libraries import api as libraries_api from openedx.core.djangoapps.content_libraries.models import ContentLibrary diff --git a/cms/envs/common.py b/cms/envs/common.py index 56407372d479..9e23fd6bdd5e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1834,8 +1834,10 @@ "openedx_learning.apps.authoring.units", "openedx_learning.apps.authoring.subsections", "openedx_learning.apps.authoring.sections", - "openedx_learning.apps.authoring.outline_roots", - "openedx_learning.apps.authoring.courses", + + # Learning Core courses prototype + 'openedx.core.djangoapps.content.outline_roots', + 'openedx.core.djangoapps.content.courses', ] diff --git a/lms/envs/common.py b/lms/envs/common.py index bde836c3938e..248dfeb17859 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3375,8 +3375,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring "openedx_learning.apps.authoring.units", "openedx_learning.apps.authoring.subsections", "openedx_learning.apps.authoring.sections", - "openedx_learning.apps.authoring.outline_roots", - "openedx_learning.apps.authoring.courses", + + # Learning Core courses prototype + 'openedx.core.djangoapps.content.outline_roots', + 'openedx.core.djangoapps.content.courses', ] diff --git a/openedx/core/djangoapps/content/courses/__init__.py b/openedx/core/djangoapps/content/courses/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content/courses/admin.py b/openedx/core/djangoapps/content/courses/admin.py new file mode 100644 index 000000000000..2158ac0b918c --- /dev/null +++ b/openedx/core/djangoapps/content/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/core/djangoapps/content/courses/api.py b/openedx/core/djangoapps/content/courses/api.py new file mode 100644 index 000000000000..1ed9921ef074 --- /dev/null +++ b/openedx/core/djangoapps/content/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/core/djangoapps/content/courses/apps.py b/openedx/core/djangoapps/content/courses/apps.py new file mode 100644 index 000000000000..c62100ede1da --- /dev/null +++ b/openedx/core/djangoapps/content/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.core.djangoapps.content.courses" + verbose_name = "Learning Core Course Prototype > Courses" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_courses" diff --git a/openedx/core/djangoapps/content/courses/migrations/0001_initial.py b/openedx/core/djangoapps/content/courses/migrations/0001_initial.py new file mode 100644 index 000000000000..c87d6772fd0c --- /dev/null +++ b/openedx/core/djangoapps/content/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/core/djangoapps/content/courses/migrations/__init__.py b/openedx/core/djangoapps/content/courses/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content/courses/models.py b/openedx/core/djangoapps/content/courses/models.py new file mode 100644 index 000000000000..b4d180097714 --- /dev/null +++ b/openedx/core/djangoapps/content/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 openedx_learning.api.authoring_models import LearningPackage + +from openedx_learning.lib.fields import case_insensitive_char_field +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 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/core/djangoapps/content/outline_roots/__init__.py b/openedx/core/djangoapps/content/outline_roots/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content/outline_roots/admin.py b/openedx/core/djangoapps/content/outline_roots/admin.py new file mode 100644 index 000000000000..a2bb310f7f27 --- /dev/null +++ b/openedx/core/djangoapps/content/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/core/djangoapps/content/outline_roots/api.py b/openedx/core/djangoapps/content/outline_roots/api.py new file mode 100644 index 000000000000..29d9126180ec --- /dev/null +++ b/openedx/core/djangoapps/content/outline_roots/api.py @@ -0,0 +1,267 @@ +"""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.api.authoring_models import ( + Container, + ContainerVersion, + Section, + SectionVersion, + Subsection, + SubsectionVersion, + Unit, + UnitVersion, +) +from openedx_learning.api import authoring as authoring_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, +) -> 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 authoring_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[authoring_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 authoring_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[authoring_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 [ + ( + authoring_api.ContainerEntityRow( + entity_pk=s.container.publishable_entity_id, + version_pk=None, + ) if isinstance(s, (Section, Subsection, Unit)) + else authoring_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: authoring_api.ChildrenEntitiesAction = authoring_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 authoring_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 authoring_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/core/djangoapps/content/outline_roots/apps.py b/openedx/core/djangoapps/content/outline_roots/apps.py new file mode 100644 index 000000000000..2a124f8bce9c --- /dev/null +++ b/openedx/core/djangoapps/content/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.core.djangoapps.content.outline_roots" + verbose_name = "Learning Core Course Prototype > Outline Roots" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_outline_roots" + + def ready(self): + """ + Register Section and SectionVersion. + """ + from openedx_learning.api.authoring 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/core/djangoapps/content/outline_roots/migrations/0001_initial.py b/openedx/core/djangoapps/content/outline_roots/migrations/0001_initial.py new file mode 100644 index 000000000000..a5f396df6b31 --- /dev/null +++ b/openedx/core/djangoapps/content/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/core/djangoapps/content/outline_roots/migrations/__init__.py b/openedx/core/djangoapps/content/outline_roots/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content/outline_roots/models.py b/openedx/core/djangoapps/content/outline_roots/models.py new file mode 100644 index 000000000000..77982a625139 --- /dev/null +++ b/openedx/core/djangoapps/content/outline_roots/models.py @@ -0,0 +1,57 @@ +""" +Models that implement the "outline root" for each course +""" +from django.db import models + +from openedx_learning.api.authoring_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/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index d345bd22ebe0..d691961d0588 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -26,8 +26,10 @@ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Container, ContainerVersion, Component +from openedx.core.djangoapps.content.outline_roots import api as outline_roots_api from openedx.core.djangoapps.content_libraries.api.collections import library_collection_locator from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts +from openedx.core.djangoapps.content.outline_roots import api as outline_root_api from openedx.core.djangoapps.xblock.api import create_xblock_field_data_for_container, get_component_from_usage_key from ..models import ContentLibrary @@ -88,6 +90,8 @@ def container_model_classes(self) -> tuple[type[Container], type[ContainerVersio SubsectionVersion, Section, SectionVersion, + ) + from openedx.core.djangoapps.content.outline_roots.models import ( OutlineRoot, OutlineRootVersion, ) @@ -321,7 +325,7 @@ def create_container( created_by=user_id, ) case ContainerType.OutlineRoot: - container, initial_version = authoring_api.create_outline_root_and_version( + container, initial_version = outline_roots_api.create_outline_root_and_version( content_library.learning_package_id, key=slug, title=title, @@ -385,7 +389,7 @@ def update_container( ) affected_containers = get_containers_contains_item(container_key) case ContainerType.OutlineRoot: - version = authoring_api.create_next_outline_root_version( + version = outline_roots_api.create_next_outline_root_version( container.outlineroot, title=display_name, created=created, @@ -627,7 +631,7 @@ def update_container_children( ) case ContainerType.OutlineRoot: subsections = [_get_container_from_key(key).outlineroot for key in children_ids] # type: ignore[arg-type] - new_version = authoring_api.create_next_outline_root_version( + new_version = outline_roots_api.create_next_outline_root_version( container.outlineroot, sections=sections, # type: ignore[arg-type] created=created, diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 10c7fa8aa27b..4d41804e700a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -840,7 +840,7 @@ openedx-filters==2.1.0 # ora2 openedx-forum==0.3.0 # via -r requirements/edx/kernel.in --e git+https://github.com/openedx/openedx-learning.git@lc-course-prototype#egg=openedx-learning +-e git+https://github.com/kdmccormick/openedx-learning.git@kdmccormick/admin#egg=openedx-learning # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 853f829fc85a..35da70d46a9a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1400,7 +1400,7 @@ openedx-forum==0.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt --e git+https://github.com/openedx/openedx-learning.git@lc-course-prototype#egg=openedx-learning +-e git+https://github.com/kdmccormick/openedx-learning.git@kdmccormick/admin#egg=openedx-learning # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 89f102ac8202..70253ef42abd 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1005,7 +1005,7 @@ openedx-filters==2.1.0 # ora2 openedx-forum==0.3.0 # via -r requirements/edx/base.txt --e git+https://github.com/openedx/openedx-learning.git@lc-course-prototype#egg=openedx-learning +-e git+https://github.com/kdmccormick/openedx-learning.git@kdmccormick/admin#egg=openedx-learning # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 666c37fd5e1c..d3d44ee3e25d 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -73,7 +73,7 @@ # # * Organize the URL into one of the two categories below: --e git+https://github.com/openedx/openedx-learning.git@lc-course-prototype#egg=openedx-learning +-e git+https://github.com/kdmccormick/openedx-learning.git@kdmccormick/admin#egg=openedx-learning ############################################################################## # Release candidates being tested. diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index d002f38b768a..3a7fba09aedd 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1064,7 +1064,7 @@ openedx-filters==2.1.0 # ora2 openedx-forum==0.3.0 # via -r requirements/edx/base.txt --e git+https://github.com/openedx/openedx-learning.git@lc-course-prototype#egg=openedx-learning +-e git+https://github.com/kdmccormick/openedx-learning.git@kdmccormick/admin#egg=openedx-learning # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt