From bd9d685073c668c061646f1ba3117974f8161b1c Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 6 Feb 2026 15:59:21 -0800 Subject: [PATCH 1/2] feat: depend on edx-organizations --- requirements/base.in | 2 ++ requirements/base.txt | 20 ++++++++++++++++++-- requirements/dev.txt | 23 ++++++++++++++++++++++- requirements/doc.txt | 23 ++++++++++++++++++++++- requirements/quality.txt | 23 ++++++++++++++++++++++- requirements/test.txt | 23 ++++++++++++++++++++++- 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/requirements/base.in b/requirements/base.in index 15bcd9748..508635e10 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,3 +13,5 @@ edx-drf-extensions # Extensions to the Django REST Framework used by Open rules<4.0 # Django extension for rules-based authorization checks tomlkit # Parses and writes TOML configuration files + +edx-organizations # Implemented the "Organization" model that CatalogCourse/CourseRun are keyed to diff --git a/requirements/base.txt b/requirements/base.txt index 180b9cf26..fd3540ecb 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -42,13 +42,20 @@ django==5.2.11 # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # django-crum + # django-model-utils + # django-simple-history # django-waffle # djangorestframework # drf-jwt # edx-django-utils # edx-drf-extensions + # edx-organizations django-crum==0.7.9 # via edx-django-utils +django-model-utils==5.0.0 + # via edx-organizations +django-simple-history==3.11.0 + # via edx-organizations django-waffle==5.0.0 # via # edx-django-utils @@ -58,6 +65,7 @@ djangorestframework==3.16.1 # -r requirements/base.in # drf-jwt # edx-drf-extensions + # edx-organizations dnspython==2.8.0 # via pymongo drf-jwt==1.19.2 @@ -65,15 +73,23 @@ drf-jwt==1.19.2 edx-django-utils==8.0.1 # via edx-drf-extensions edx-drf-extensions==10.6.0 - # via -r requirements/base.in + # via + # -r requirements/base.in + # edx-organizations edx-opaque-keys==3.0.0 - # via edx-drf-extensions + # via + # edx-drf-extensions + # edx-organizations +edx-organizations==7.3.0 + # via -r requirements/base.in idna==3.11 # via requests kombu==5.6.2 # via celery packaging==26.0 # via kombu +pillow==12.1.0 + # via edx-organizations prompt-toolkit==3.0.52 # via click-repl psutil==7.2.2 diff --git a/requirements/dev.txt b/requirements/dev.txt index 42895170e..bd7e64dca 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -132,6 +132,8 @@ django==5.2.11 # -r requirements/quality.txt # django-crum # django-debug-toolbar + # django-model-utils + # django-simple-history # django-stubs # django-stubs-ext # django-waffle @@ -140,6 +142,7 @@ django==5.2.11 # edx-django-utils # edx-drf-extensions # edx-i18n-tools + # edx-organizations django-crum==0.7.9 # via # -r requirements/quality.txt @@ -148,6 +151,14 @@ django-debug-toolbar==6.2.0 # via # -r requirements/dev.in # -r requirements/quality.txt +django-model-utils==5.0.0 + # via + # -r requirements/quality.txt + # edx-organizations +django-simple-history==3.11.0 + # via + # -r requirements/quality.txt + # edx-organizations django-stubs==5.2.9 # via # -r requirements/quality.txt @@ -166,6 +177,7 @@ djangorestframework==3.16.1 # -r requirements/quality.txt # drf-jwt # edx-drf-extensions + # edx-organizations djangorestframework-stubs==3.16.8 # via -r requirements/quality.txt dnspython==2.8.0 @@ -185,7 +197,9 @@ edx-django-utils==8.0.1 # -r requirements/quality.txt # edx-drf-extensions edx-drf-extensions==10.6.0 - # via -r requirements/quality.txt + # via + # -r requirements/quality.txt + # edx-organizations edx-i18n-tools==1.9.0 # via -r requirements/dev.in edx-lint==5.6.0 @@ -194,6 +208,9 @@ edx-opaque-keys==3.0.0 # via # -r requirements/quality.txt # edx-drf-extensions + # edx-organizations +edx-organizations==7.3.0 + # via -r requirements/quality.txt fastapi==0.128.5 # via # -r requirements/quality.txt @@ -324,6 +341,10 @@ pathspec==1.0.4 # via # -r requirements/quality.txt # mypy +pillow==12.1.0 + # via + # -r requirements/quality.txt + # edx-organizations pip-tools==7.5.2 # via -r requirements/pip-tools.txt platformdirs==4.5.1 diff --git a/requirements/doc.txt b/requirements/doc.txt index 7b3fe0985..c7fbf9636 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -96,6 +96,8 @@ django==5.2.11 # -r requirements/test.txt # django-crum # django-debug-toolbar + # django-model-utils + # django-simple-history # django-stubs # django-stubs-ext # django-waffle @@ -103,6 +105,7 @@ django==5.2.11 # drf-jwt # edx-django-utils # edx-drf-extensions + # edx-organizations # sphinxcontrib-django django-crum==0.7.9 # via @@ -110,6 +113,14 @@ django-crum==0.7.9 # edx-django-utils django-debug-toolbar==6.2.0 # via -r requirements/test.txt +django-model-utils==5.0.0 + # via + # -r requirements/test.txt + # edx-organizations +django-simple-history==3.11.0 + # via + # -r requirements/test.txt + # edx-organizations django-stubs==5.2.9 # via # -r requirements/test.txt @@ -128,6 +139,7 @@ djangorestframework==3.16.1 # -r requirements/test.txt # drf-jwt # edx-drf-extensions + # edx-organizations djangorestframework-stubs==3.16.8 # via -r requirements/test.txt dnspython==2.8.0 @@ -152,11 +164,16 @@ edx-django-utils==8.0.1 # -r requirements/test.txt # edx-drf-extensions edx-drf-extensions==10.6.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # edx-organizations edx-opaque-keys==3.0.0 # via # -r requirements/test.txt # edx-drf-extensions + # edx-organizations +edx-organizations==7.3.0 + # via -r requirements/test.txt fastapi==0.128.5 # via # -r requirements/test.txt @@ -232,6 +249,10 @@ pathspec==1.0.4 # via # -r requirements/test.txt # mypy +pillow==12.1.0 + # via + # -r requirements/test.txt + # edx-organizations pluggy==1.6.0 # via # -r requirements/test.txt diff --git a/requirements/quality.txt b/requirements/quality.txt index ce7066d2f..c3a7c7898 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -100,6 +100,8 @@ django==5.2.11 # -r requirements/test.txt # django-crum # django-debug-toolbar + # django-model-utils + # django-simple-history # django-stubs # django-stubs-ext # django-waffle @@ -107,12 +109,21 @@ django==5.2.11 # drf-jwt # edx-django-utils # edx-drf-extensions + # edx-organizations django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils django-debug-toolbar==6.2.0 # via -r requirements/test.txt +django-model-utils==5.0.0 + # via + # -r requirements/test.txt + # edx-organizations +django-simple-history==3.11.0 + # via + # -r requirements/test.txt + # edx-organizations django-stubs==5.2.9 # via # -r requirements/test.txt @@ -131,6 +142,7 @@ djangorestframework==3.16.1 # -r requirements/test.txt # drf-jwt # edx-drf-extensions + # edx-organizations djangorestframework-stubs==3.16.8 # via -r requirements/test.txt dnspython==2.8.0 @@ -148,13 +160,18 @@ edx-django-utils==8.0.1 # -r requirements/test.txt # edx-drf-extensions edx-drf-extensions==10.6.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # edx-organizations edx-lint==5.6.0 # via -r requirements/quality.in edx-opaque-keys==3.0.0 # via # -r requirements/test.txt # edx-drf-extensions + # edx-organizations +edx-organizations==7.3.0 + # via -r requirements/test.txt fastapi==0.128.5 # via # -r requirements/test.txt @@ -248,6 +265,10 @@ pathspec==1.0.4 # via # -r requirements/test.txt # mypy +pillow==12.1.0 + # via + # -r requirements/test.txt + # edx-organizations platformdirs==4.5.1 # via pylint pluggy==1.6.0 diff --git a/requirements/test.txt b/requirements/test.txt index cda2ae220..214c1d373 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -79,6 +79,8 @@ ddt==1.7.2 # -r requirements/base.txt # django-crum # django-debug-toolbar + # django-model-utils + # django-simple-history # django-stubs # django-stubs-ext # django-waffle @@ -86,12 +88,21 @@ ddt==1.7.2 # drf-jwt # edx-django-utils # edx-drf-extensions + # edx-organizations django-crum==0.7.9 # via # -r requirements/base.txt # edx-django-utils django-debug-toolbar==6.2.0 # via -r requirements/test.in +django-model-utils==5.0.0 + # via + # -r requirements/base.txt + # edx-organizations +django-simple-history==3.11.0 + # via + # -r requirements/base.txt + # edx-organizations django-stubs==5.2.9 # via # -r requirements/test.in @@ -108,6 +119,7 @@ djangorestframework==3.16.1 # -r requirements/base.txt # drf-jwt # edx-drf-extensions + # edx-organizations djangorestframework-stubs==3.16.8 # via -r requirements/test.in dnspython==2.8.0 @@ -123,11 +135,16 @@ edx-django-utils==8.0.1 # -r requirements/base.txt # edx-drf-extensions edx-drf-extensions==10.6.0 - # via -r requirements/base.txt + # via + # -r requirements/base.txt + # edx-organizations edx-opaque-keys==3.0.0 # via # -r requirements/base.txt # edx-drf-extensions + # edx-organizations +edx-organizations==7.3.0 + # via -r requirements/base.txt fastapi==0.128.5 # via import-linter freezegun==1.5.5 @@ -174,6 +191,10 @@ packaging==26.0 # pytest pathspec==1.0.4 # via mypy +pillow==12.1.0 + # via + # -r requirements/base.txt + # edx-organizations pluggy==1.6.0 # via # pytest From 8a62c9799301a7f88a965d794658ebb2fd4e7570 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Feb 2026 11:50:34 -0800 Subject: [PATCH 2/2] feat: new catalog app to model course runs and catalog courses --- projects/dev.py | 3 + src/openedx_catalog/ARCHITECTURE.md | 35 +++ src/openedx_catalog/README.rst | 22 ++ src/openedx_catalog/__init__.py | 0 src/openedx_catalog/admin.py | 64 ++++++ src/openedx_catalog/apps.py | 19 ++ .../migrations/0001_initial.py | 165 +++++++++++++++ src/openedx_catalog/migrations/__init__.py | 0 src/openedx_catalog/models/__init__.py | 6 + src/openedx_catalog/models/catalog_course.py | 143 +++++++++++++ src/openedx_catalog/models/course_run.py | 193 +++++++++++++++++ src/openedx_catalog/py.typed | 2 + test_settings.py | 3 + tests/openedx_catalog/__init__.py | 0 tests/openedx_catalog/test_models.py | 199 ++++++++++++++++++ 15 files changed, 854 insertions(+) create mode 100644 src/openedx_catalog/ARCHITECTURE.md create mode 100644 src/openedx_catalog/README.rst create mode 100644 src/openedx_catalog/__init__.py create mode 100644 src/openedx_catalog/admin.py create mode 100644 src/openedx_catalog/apps.py create mode 100644 src/openedx_catalog/migrations/0001_initial.py create mode 100644 src/openedx_catalog/migrations/__init__.py create mode 100644 src/openedx_catalog/models/__init__.py create mode 100644 src/openedx_catalog/models/catalog_course.py create mode 100644 src/openedx_catalog/models/course_run.py create mode 100644 src/openedx_catalog/py.typed create mode 100644 tests/openedx_catalog/__init__.py create mode 100644 tests/openedx_catalog/test_models.py diff --git a/projects/dev.py b/projects/dev.py index 9f7488344..b876b2076 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -34,6 +34,9 @@ "django.contrib.admin", "django.contrib.admindocs", + # Open edX Organizations (dependency for openedx_catalog) + "organizations", + # Our Apps "openedx_tagging", "openedx_content", diff --git a/src/openedx_catalog/ARCHITECTURE.md b/src/openedx_catalog/ARCHITECTURE.md new file mode 100644 index 000000000..6feda9546 --- /dev/null +++ b/src/openedx_catalog/ARCHITECTURE.md @@ -0,0 +1,35 @@ +# Catalog App Architecture Diagram + +Here's a visual overview of how this app relates to other apps. + +(_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._) + +```mermaid +--- +config: + theme: 'forest' +--- +flowchart TB + 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.)"] + Content["**openedx_content**
The content of the course. (publishing, containers, components, media)"] + Organizations["**edx-organizations** (Organization)"] + Enrollments["**platform: enrollments** (CourseEnrollment, CourseEnrollmentAllowed)"] + Modes["**platform: course_modes** (CourseMode)"] + Catalog <-. "Direction of this relationship TBD." .-> Content + Catalog -- References --> Organizations + Enrollments -- References --> Modes + Enrollments -- References --> Catalog + + style Enrollments fill:#ccc + style Modes fill:#ccc + style Organizations fill:#ccc + + Pathways["**openedx_pathways** (Pathway, PathwaySchedule, PathwayEnrollment, PathwayCertificate, etc.)"] + Pathways -- References --> Catalog + + style Pathways fill:#c0ffee,stroke-dasharray: 5 5 + + FutureCatalog["Future discovery service - learner-oriented, pluggable, browse/search courses and programs"] -- References --> Catalog + FutureCatalog <-- Plugin API --> Pathways + style FutureCatalog fill:#ffc0ee,stroke-dasharray: 5 5 +``` \ No newline at end of file diff --git a/src/openedx_catalog/README.rst b/src/openedx_catalog/README.rst new file mode 100644 index 000000000..f7ca28a0c --- /dev/null +++ b/src/openedx_catalog/README.rst @@ -0,0 +1,22 @@ +Learning Core: Catalog App +========================== + +Overview +-------- + +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. + +Motivation +---------- + +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: + +1. Provide a core model to represent each course, for foreign key purposes. +2. To allow provisioning placeholder courses before any content even exists. +3. To be much simpler and more performant than ``CourseOverview`` was (far fewer fields generally, fewer legacy fields, integer primary key). +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. + +Architecture +------------ + +See `the architecture diagram <./ARCHITECTURE.md>`__. (Because we use RST for all Python READMEs, it cannot be embedded here directly.) diff --git a/src/openedx_catalog/__init__.py b/src/openedx_catalog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/openedx_catalog/admin.py b/src/openedx_catalog/admin.py new file mode 100644 index 000000000..9696399c6 --- /dev/null +++ b/src/openedx_catalog/admin.py @@ -0,0 +1,64 @@ +""" +Django Admin pages for openedx_catalog. +""" + +from django.contrib import admin +from django.db.models import Count +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from .models import CatalogCourse, CourseRun + + +class CatalogCourseAdmin(admin.ModelAdmin): + """ + The CatalogCourse model admin. + """ + + list_filter = ["org_id", "language"] + list_display = ["display_name", "org", "course_code", "runs_summary", "language"] + + def get_readonly_fields(self, request, obj: CatalogCourse | None = None): + if obj: # editing an existing object + return self.readonly_fields + ("org", "course_code") + return self.readonly_fields + + def get_queryset(self, request): + """Add the 'run_count' to the list_display queryset""" + qs = super().get_queryset(request) + qs = qs.annotate(run_count=Count("runs")) + return qs + + def runs_summary(self, obj: CatalogCourse) -> str: + """Summarize the runs""" + if obj.run_count == 0: + return "-" + url = reverse("admin:openedx_catalog_courserun_changelist") + f"?catalog_course={obj.pk}" + first_few_runs = obj.runs.order_by("-run")[:3] + runs_summary = ", ".join(run.run for run in first_few_runs) + if obj.run_count > 4: + runs_summary += f", ... ({obj.runs_count})" + return format_html('{}', url, runs_summary) + + +admin.site.register(CatalogCourse, CatalogCourseAdmin) + + +class CourseRunAdmin(admin.ModelAdmin): + """ + The CourseRun model admin. + """ + + list_display = ["display_name", "catalog_course", "run", "org_id"] + readonly_fields = ("course_id",) + # There may be thousands of catalog courses, so don't use