-
Notifications
You must be signed in to change notification settings - Fork 18
Prototype models for Courses and Outline Roots in Learning Core #316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7dfcc1e
feat: OutlineRoot model
bradenmacdonald 28bddc7
feat: CatalogCourse + Course[Run] models
bradenmacdonald ed62ce7
feat: build out the courses API more
bradenmacdonald 648d978
test: minimal test cases for courses
bradenmacdonald 4a662a2
feat: relax requirement that all OutlineRoot children are the same
bradenmacdonald 6d793f3
fix: quality/typing issue (work around mypy limitation)
bradenmacdonald cf4a19f
chore: minor quality fix
bradenmacdonald File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
53
openedx_learning/apps/authoring/courses/migrations/0001_initial.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ormsbee @kdmccormick So do you think I should remove the CatalogCourse model because we don't want it in the authoring domain, and just put org/course_id into the course run model?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My gut says to remove CatalogCourse. Does that make the prototype any more difficult?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mmm, I don't think it's any more difficult either way.