diff --git a/projects/dev.py b/projects/dev.py
index 9f748834..b876b207 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/requirements/base.in b/requirements/base.in
index 15bcd974..508635e1 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 180b9cf2..fd3540ec 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 42895170..bd7e64dc 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 7b3fe098..c7fbf963 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 ce7066d2..c3a7c789 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 cda2ae22..214c1d37 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
diff --git a/src/openedx_catalog/ARCHITECTURE.md b/src/openedx_catalog/ARCHITECTURE.md
new file mode 100644
index 00000000..6feda954
--- /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 00000000..f7ca28a0
--- /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 00000000..e69de29b
diff --git a/src/openedx_catalog/admin.py b/src/openedx_catalog/admin.py
new file mode 100644
index 00000000..9696399c
--- /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