Skip to content

Commit 8177a58

Browse files
feat: new catalog app to model course runs and catalog courses
1 parent 71ec47e commit 8177a58

15 files changed

Lines changed: 853 additions & 0 deletions

File tree

projects/dev.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"django.contrib.admin",
3535
"django.contrib.admindocs",
3636

37+
# Open edX Organizations (dependency for openedx_catalog)
38+
"organizations",
39+
3740
# Our Apps
3841
"openedx_tagging",
3942
"openedx_content",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Catalog App Architecture Diagram
2+
3+
Here's a visual overview of how this app relates to other apps.
4+
5+
(_Note: to see the diagram below, view this on GitHub or view in VS Code with [a Markdown-Mermaid extension](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) enabled._)
6+
7+
```mermaid
8+
---
9+
config:
10+
theme: 'forest'
11+
---
12+
flowchart TB
13+
Catalog["**openedx_catalog** (CourseRun, CatalogCourse plus core metadata models, e.g. CourseSchedule. Other metadata models live in other apps but are 1:1 with CourseRun.)"]
14+
Content["**openedx_content**<br>The content of the course. (publishing, containers, components, media)"]
15+
Organizations["**edx-organizations** (Organization)"]
16+
Enrollments["**platform: enrollments** (CourseEnrollment, CourseEnrollmentAllowed)"]
17+
Modes["**platform: course_modes** (CourseMode)"]
18+
Catalog <-. "Direction of this relationship TBD." .-> Content
19+
Catalog -- References --> Organizations
20+
Enrollments -- References --> Modes
21+
Enrollments -- References --> Catalog
22+
23+
style Enrollments fill:#ccc
24+
style Modes fill:#ccc
25+
style Organizations fill:#ccc
26+
27+
Pathways["<a href='https://openedx.atlassian.net/wiki/spaces/OEPM/pages/5148147732/Brief+Modular+Content+Delivery+-+Platform+Strategy'>**openedx_pathways**</a> (Pathway, PathwaySchedule, PathwayEnrollment, PathwayCertificate, etc.)"]
28+
Pathways -- References --> Catalog
29+
30+
style Pathways fill:#c0ffee,stroke-dasharray: 5 5
31+
32+
FutureCatalog["Future discovery service - learner-oriented, pluggable, browse/search courses and programs"] -- References --> Catalog
33+
FutureCatalog <-- Plugin API --> Pathways
34+
style FutureCatalog fill:#ffc0ee,stroke-dasharray: 5 5
35+
```

src/openedx_catalog/README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Learning Core: Catalog App
2+
==========================
3+
4+
Overview
5+
--------
6+
7+
The ``openedx_catalog`` Django apps provides core models to represent all courses in the Open edX platform. Higher-level apps can build on these models to implement features like enrollment, grading, scheduling, exams, and much more.
8+
9+
Motivation
10+
----------
11+
12+
The existing ``CourseOverview`` model in ``openedx-platform`` is derived from various places, but mostly from the metadata fields of the root ``Course`` object stored in modulestore (MongoDB) for each course. As we slowly transition toward storing course content fully in Learning Core (in ``openedx_content``), we want to move to storing all course data and metadata in these sort of MySQL models. We're creating this new ``CourseRun`` model in ``openedx_catalog`` to support these goals:
13+
14+
1. Provide a core model to represent each course, for foreign key purposes.
15+
2. To allow provisioning placeholder courses before any content even exists.
16+
3. To be much simpler and more performant than ``CourseOverview`` was (far fewer fields generally, fewer legacy fields, integer primary key).
17+
4. Perhaps to provide a transition mechanism, a pointer than can point either to modulestore content or learning core content, as we transition content storage.
18+
19+
Architecture
20+
------------
21+
22+
See `the architecture diagram <./ARCHITECTURE.md>`__. (Because we use RST for all Python READMEs, it cannot be embedded here directly.)

src/openedx_catalog/__init__.py

Whitespace-only changes.

src/openedx_catalog/admin.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Django Admin pages for openedx_catalog.
3+
"""
4+
5+
from django.contrib import admin
6+
from django.db.models import Count
7+
from django.urls import reverse
8+
from django.utils.html import format_html
9+
from django.utils.translation import gettext_lazy as _
10+
11+
from .models import CatalogCourse, CourseRun
12+
13+
14+
class CatalogCourseAdmin(admin.ModelAdmin):
15+
"""
16+
The CatalogCourse model admin.
17+
"""
18+
19+
list_filter = ["org_id", "language"]
20+
list_display = ["display_name", "org", "course_code", "runs_summary", "language"]
21+
22+
def get_readonly_fields(self, request, obj: CatalogCourse | None = None):
23+
if obj: # editing an existing object
24+
return self.readonly_fields + ("org", "course_code")
25+
return self.readonly_fields
26+
27+
def get_queryset(self, request):
28+
"""Add the 'run_count' to the list_display queryset"""
29+
qs = super().get_queryset(request)
30+
qs = qs.annotate(run_count=Count("runs"))
31+
return qs
32+
33+
def runs_summary(self, obj: CatalogCourse) -> str:
34+
"""Summarize the runs"""
35+
if obj.run_count == 0:
36+
return "-"
37+
url = reverse("admin:openedx_catalog_courserun_changelist") + f"?catalog_course={obj.pk}"
38+
first_few_runs = obj.runs.order_by("-run")[:3]
39+
runs_summary = ", ".join(run.run for run in first_few_runs)
40+
if obj.run_count > 4:
41+
runs_summary += f", ... ({obj.runs_count})"
42+
return format_html('<a href="{}">{}</a>', url, runs_summary)
43+
44+
45+
admin.site.register(CatalogCourse, CatalogCourseAdmin)
46+
47+
48+
class CourseRunAdmin(admin.ModelAdmin):
49+
"""
50+
The CourseRun model admin.
51+
"""
52+
53+
list_display = ["display_name", "catalog_course", "run", "org_id"]
54+
readonly_fields = ("course_id",)
55+
# There may be thousands of catalog courses, so don't use <select>
56+
raw_id_fields = ["catalog_course"]
57+
58+
def get_readonly_fields(self, request, obj: CourseRun | None = None):
59+
if obj: # editing an existing object
60+
return self.readonly_fields + ("run",)
61+
return self.readonly_fields
62+
63+
64+
admin.site.register(CourseRun, CourseRunAdmin)

src/openedx_catalog/apps.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
App Config for the openedx_catalog app.
3+
"""
4+
5+
from django.apps import AppConfig
6+
7+
8+
class CatalogAppConfig(AppConfig):
9+
"""
10+
Initialize and configure the Catalog app
11+
"""
12+
13+
name = "openedx_catalog"
14+
verbose_name = "Open edX Core > Catalog"
15+
default_auto_field = "django.db.models.BigAutoField"
16+
label = "openedx_catalog"
17+
18+
def ready(self) -> None:
19+
pass
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Generated by Django 5.2.11 on 2026-02-10 03:08
2+
3+
import django.db.models.deletion
4+
import opaque_keys.edx.django.models
5+
import openedx_catalog.models.catalog_course
6+
import openedx_django_lib.fields
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
initial = True
12+
13+
dependencies = [
14+
("organizations", "0004_auto_20230727_2054"),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name="CatalogCourse",
20+
fields=[
21+
(
22+
"id",
23+
models.BigAutoField(
24+
editable=False,
25+
help_text="The internal database ID for this catalog course. Should not be exposed to users nor in APIs.",
26+
primary_key=True,
27+
serialize=False,
28+
verbose_name="Primary Key",
29+
),
30+
),
31+
(
32+
"course_code",
33+
openedx_django_lib.fields.MultiCollationCharField(
34+
db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
35+
help_text='The course ID, e.g. "Math100".',
36+
max_length=255,
37+
),
38+
),
39+
(
40+
"display_name",
41+
models.CharField(
42+
help_text='The full name of this catalog course. e.g. "Introduction to Calculus". Individual course runs may override this, e.g. "Into to Calc (Fall 2026 with Dr. Newton)".',
43+
max_length=255,
44+
),
45+
),
46+
(
47+
"language",
48+
models.CharField(
49+
default=openedx_catalog.models.catalog_course.get_default_language_code,
50+
help_text='The code representing the language of this catalog course\'s content. The first two digits must be the lowercase ISO 639-1 language code. e.g. "en", "es", "en-us", "pt-br". ',
51+
max_length=64,
52+
),
53+
),
54+
(
55+
"org",
56+
models.ForeignKey(
57+
on_delete=django.db.models.deletion.PROTECT,
58+
to="organizations.organization",
59+
to_field="short_name",
60+
),
61+
),
62+
],
63+
options={
64+
"verbose_name": "Catalog Course",
65+
"verbose_name_plural": "Catalog Courses",
66+
},
67+
),
68+
migrations.CreateModel(
69+
name="CourseRun",
70+
fields=[
71+
(
72+
"id",
73+
models.BigAutoField(
74+
editable=False,
75+
help_text="The internal database ID for this course. Should not be exposed to users nor in APIs.",
76+
primary_key=True,
77+
serialize=False,
78+
verbose_name="Primary Key",
79+
),
80+
),
81+
(
82+
"course_id",
83+
opaque_keys.edx.django.models.CourseKeyField(
84+
editable=False,
85+
help_text="The main identifier for this course. Includes the org, course code, and run.",
86+
max_length=255,
87+
unique=True,
88+
verbose_name="Course ID",
89+
),
90+
),
91+
(
92+
"run",
93+
openedx_django_lib.fields.MultiCollationCharField(
94+
db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
95+
help_text='The code that identifies this particular run of the course, e.g. "2026", "2026Fall" or "2T2026"',
96+
max_length=128,
97+
),
98+
),
99+
(
100+
"display_name",
101+
models.CharField(
102+
blank=True,
103+
help_text='The full name of this course. e.g. "Introduction to Calculus". This is required and will override the name of the catalog course. Leave blank to use the same name as the catalog course. ',
104+
max_length=255,
105+
),
106+
),
107+
(
108+
"catalog_course",
109+
models.ForeignKey(
110+
on_delete=django.db.models.deletion.PROTECT,
111+
related_name="runs",
112+
to="openedx_catalog.catalogcourse",
113+
),
114+
),
115+
],
116+
options={
117+
"verbose_name": "Course Run",
118+
"verbose_name_plural": "Course Runs",
119+
},
120+
),
121+
migrations.AddConstraint(
122+
model_name="catalogcourse",
123+
constraint=models.UniqueConstraint(
124+
fields=("org", "course_code"), name="oex_catalog_catalog_course_org_code_pair_uniq"
125+
),
126+
),
127+
migrations.AddConstraint(
128+
model_name="catalogcourse",
129+
constraint=models.CheckConstraint(
130+
condition=models.Q(("course_code__length__gt", 0)),
131+
name="oex_catalog_catalogcourse_course_code_not_blank",
132+
),
133+
),
134+
migrations.AddConstraint(
135+
model_name="catalogcourse",
136+
constraint=models.CheckConstraint(
137+
condition=models.Q(("language__length__gt", 0)), name="oex_catalog_catalogcourse_language_not_blank"
138+
),
139+
),
140+
migrations.AddConstraint(
141+
model_name="catalogcourse",
142+
constraint=models.CheckConstraint(
143+
condition=models.Q(("display_name__length__gt", 0)),
144+
name="oex_catalog_catalogcourse_display_name_not_blank",
145+
),
146+
),
147+
migrations.AddConstraint(
148+
model_name="courserun",
149+
constraint=models.UniqueConstraint(
150+
fields=("catalog_course", "run"), name="oex_catalog_courserun_catalog_course_run_uniq"
151+
),
152+
),
153+
migrations.AddConstraint(
154+
model_name="courserun",
155+
constraint=models.CheckConstraint(
156+
condition=models.Q(("run__length__gt", 0)), name="oex_catalog_courserun_run_not_blank"
157+
),
158+
),
159+
migrations.AddConstraint(
160+
model_name="courserun",
161+
constraint=models.CheckConstraint(
162+
condition=models.Q(("display_name__length__gt", 0)), name="oex_catalog_courserun_display_name_not_blank"
163+
),
164+
),
165+
]

src/openedx_catalog/migrations/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Models that commprise openedx_catalog
3+
"""
4+
5+
from .catalog_course import CatalogCourse
6+
from .course_run import CourseRun

0 commit comments

Comments
 (0)