Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openedx_learning/api/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
2 changes: 2 additions & 0 deletions openedx_learning/api/authoring_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
Empty file.
22 changes: 22 additions & 0 deletions openedx_learning/apps/authoring/courses/admin.py
Original file line number Diff line number Diff line change
@@ -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
"""
89 changes: 89 additions & 0 deletions openedx_learning/apps/authoring/courses/api.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions openedx_learning/apps/authoring/courses/apps.py
Original file line number Diff line number Diff line change
@@ -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"
53 changes: 53 additions & 0 deletions openedx_learning/apps/authoring/courses/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Empty file.
143 changes: 143 additions & 0 deletions openedx_learning/apps/authoring/courses/models.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
Empty file.
28 changes: 28 additions & 0 deletions openedx_learning/apps/authoring/outline_roots/admin.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading