openedx-core 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. openedx_catalog/ARCHITECTURE.md +36 -0
  2. openedx_catalog/README.rst +22 -0
  3. openedx_catalog/__init__.py +0 -0
  4. openedx_catalog/admin.py +112 -0
  5. openedx_catalog/api.py +30 -0
  6. openedx_catalog/api_impl.py +251 -0
  7. openedx_catalog/apps.py +21 -0
  8. openedx_catalog/migrations/0001_initial.py +209 -0
  9. openedx_catalog/migrations/__init__.py +0 -0
  10. openedx_catalog/models/__init__.py +6 -0
  11. openedx_catalog/models/catalog_course.py +246 -0
  12. openedx_catalog/models/course_run.py +256 -0
  13. openedx_catalog/models_api.py +10 -0
  14. openedx_catalog/py.typed +2 -0
  15. openedx_catalog/signals.py +51 -0
  16. openedx_content/README.rst +5 -0
  17. openedx_content/__init__.py +0 -0
  18. openedx_content/admin.py +11 -0
  19. openedx_content/api.py +28 -0
  20. openedx_content/applets/__init__.py +0 -0
  21. openedx_content/applets/backup_restore/__init__.py +0 -0
  22. openedx_content/applets/backup_restore/admin.py +3 -0
  23. openedx_content/applets/backup_restore/api.py +42 -0
  24. openedx_content/applets/backup_restore/models.py +3 -0
  25. openedx_content/applets/backup_restore/serializers.py +216 -0
  26. openedx_content/applets/backup_restore/toml.py +259 -0
  27. openedx_content/applets/backup_restore/zipper.py +1139 -0
  28. openedx_content/applets/collections/__init__.py +0 -0
  29. openedx_content/applets/collections/admin.py +42 -0
  30. openedx_content/applets/collections/api.py +336 -0
  31. openedx_content/applets/collections/models.py +253 -0
  32. openedx_content/applets/collections/readme.rst +23 -0
  33. openedx_content/applets/collections/signals.py +80 -0
  34. openedx_content/applets/collections/tasks.py +86 -0
  35. openedx_content/applets/components/__init__.py +0 -0
  36. openedx_content/applets/components/admin.py +159 -0
  37. openedx_content/applets/components/api.py +696 -0
  38. openedx_content/applets/components/models.py +324 -0
  39. openedx_content/applets/components/readme.rst +23 -0
  40. openedx_content/applets/containers/__init__.py +0 -0
  41. openedx_content/applets/containers/admin.py +343 -0
  42. openedx_content/applets/containers/api.py +920 -0
  43. openedx_content/applets/containers/models.py +350 -0
  44. openedx_content/applets/media/__init__.py +0 -0
  45. openedx_content/applets/media/admin.py +71 -0
  46. openedx_content/applets/media/api.py +241 -0
  47. openedx_content/applets/media/models.py +486 -0
  48. openedx_content/applets/publishing/__init__.py +0 -0
  49. openedx_content/applets/publishing/admin.py +344 -0
  50. openedx_content/applets/publishing/api.py +1793 -0
  51. openedx_content/applets/publishing/contextmanagers.py +157 -0
  52. openedx_content/applets/publishing/models/__init__.py +26 -0
  53. openedx_content/applets/publishing/models/draft_log.py +438 -0
  54. openedx_content/applets/publishing/models/learning_package.py +91 -0
  55. openedx_content/applets/publishing/models/publish_log.py +316 -0
  56. openedx_content/applets/publishing/models/publishable_entity.py +740 -0
  57. openedx_content/applets/publishing/readme.rst +28 -0
  58. openedx_content/applets/publishing/signal_handlers.py +41 -0
  59. openedx_content/applets/publishing/signals.py +247 -0
  60. openedx_content/applets/sections/__init__.py +0 -0
  61. openedx_content/applets/sections/api.py +134 -0
  62. openedx_content/applets/sections/models.py +78 -0
  63. openedx_content/applets/subsections/__init__.py +0 -0
  64. openedx_content/applets/subsections/api.py +134 -0
  65. openedx_content/applets/subsections/models.py +79 -0
  66. openedx_content/applets/units/__init__.py +0 -0
  67. openedx_content/applets/units/api.py +134 -0
  68. openedx_content/applets/units/models.py +74 -0
  69. openedx_content/apps.py +52 -0
  70. openedx_content/backcompat/__init__.py +0 -0
  71. openedx_content/backcompat/backup_restore/__init__.py +0 -0
  72. openedx_content/backcompat/backup_restore/apps.py +12 -0
  73. openedx_content/backcompat/backup_restore/migrations/__init__.py +0 -0
  74. openedx_content/backcompat/collections/__init__.py +0 -0
  75. openedx_content/backcompat/collections/apps.py +14 -0
  76. openedx_content/backcompat/collections/migrations/0001_initial.py +33 -0
  77. openedx_content/backcompat/collections/migrations/0002_remove_collection_name_collection_created_by_and_more.py +53 -0
  78. openedx_content/backcompat/collections/migrations/0003_collection_entities.py +38 -0
  79. openedx_content/backcompat/collections/migrations/0004_collection_key.py +57 -0
  80. openedx_content/backcompat/collections/migrations/0005_alter_collection_options_alter_collection_enabled.py +18 -0
  81. openedx_content/backcompat/collections/migrations/0006_remove_all_field_state_for_move_to_applet.py +82 -0
  82. openedx_content/backcompat/collections/migrations/__init__.py +0 -0
  83. openedx_content/backcompat/collections/models.py +9 -0
  84. openedx_content/backcompat/components/__init__.py +0 -0
  85. openedx_content/backcompat/components/apps.py +14 -0
  86. openedx_content/backcompat/components/migrations/0001_initial.py +97 -0
  87. openedx_content/backcompat/components/migrations/0002_alter_componentversioncontent_key.py +20 -0
  88. openedx_content/backcompat/components/migrations/0003_remove_componentversioncontent_learner_downloadable.py +17 -0
  89. openedx_content/backcompat/components/migrations/0004_remove_componentversioncontent_uuid.py +17 -0
  90. openedx_content/backcompat/components/migrations/0005_remove_all_field_state_for_move_to_applet.py +72 -0
  91. openedx_content/backcompat/components/migrations/__init__.py +0 -0
  92. openedx_content/backcompat/components/models.py +11 -0
  93. openedx_content/backcompat/contents/__init__.py +0 -0
  94. openedx_content/backcompat/contents/apps.py +14 -0
  95. openedx_content/backcompat/contents/migrations/0001_initial.py +66 -0
  96. openedx_content/backcompat/contents/migrations/0002_remove_all_field_state_for_move_to_applet.py +26 -0
  97. openedx_content/backcompat/contents/migrations/__init__.py +0 -0
  98. openedx_content/backcompat/publishing/__init__.py +0 -0
  99. openedx_content/backcompat/publishing/apps.py +14 -0
  100. openedx_content/backcompat/publishing/migrations/0001_initial.py +166 -0
  101. openedx_content/backcompat/publishing/migrations/0002_alter_learningpackage_key_and_more.py +25 -0
  102. openedx_content/backcompat/publishing/migrations/0003_containers.py +54 -0
  103. openedx_content/backcompat/publishing/migrations/0004_publishableentity_can_stand_alone.py +21 -0
  104. openedx_content/backcompat/publishing/migrations/0005_alter_entitylistrow_options.py +17 -0
  105. openedx_content/backcompat/publishing/migrations/0006_draftchangelog.py +68 -0
  106. openedx_content/backcompat/publishing/migrations/0007_bootstrap_draftchangelog.py +94 -0
  107. openedx_content/backcompat/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py +31 -0
  108. openedx_content/backcompat/publishing/migrations/0009_dependencies_and_hashing.py +62 -0
  109. openedx_content/backcompat/publishing/migrations/0010_backfill_dependencies.py +441 -0
  110. openedx_content/backcompat/publishing/migrations/0011_remove_all_field_state_for_move_to_applet.py +288 -0
  111. openedx_content/backcompat/publishing/migrations/__init__.py +0 -0
  112. openedx_content/backcompat/publishing/models.py +27 -0
  113. openedx_content/backcompat/readme.rst +6 -0
  114. openedx_content/backcompat/sections/__init__.py +0 -0
  115. openedx_content/backcompat/sections/apps.py +15 -0
  116. openedx_content/backcompat/sections/migrations/0001_initial.py +36 -0
  117. openedx_content/backcompat/sections/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
  118. openedx_content/backcompat/sections/migrations/__init__.py +0 -0
  119. openedx_content/backcompat/subsections/__init__.py +0 -0
  120. openedx_content/backcompat/subsections/apps.py +15 -0
  121. openedx_content/backcompat/subsections/migrations/0001_initial.py +36 -0
  122. openedx_content/backcompat/subsections/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
  123. openedx_content/backcompat/subsections/migrations/__init__.py +0 -0
  124. openedx_content/backcompat/units/__init__.py +0 -0
  125. openedx_content/backcompat/units/apps.py +15 -0
  126. openedx_content/backcompat/units/migrations/0001_initial.py +36 -0
  127. openedx_content/backcompat/units/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
  128. openedx_content/backcompat/units/migrations/__init__.py +0 -0
  129. openedx_content/management/__init__.py +0 -0
  130. openedx_content/management/commands/__init__.py +0 -0
  131. openedx_content/management/commands/add_assets_to_component.py +86 -0
  132. openedx_content/management/commands/lp_dump.py +69 -0
  133. openedx_content/management/commands/lp_load.py +57 -0
  134. openedx_content/migrations/0001_initial.py +654 -0
  135. openedx_content/migrations/0002_rename_tables_to_openedx_content.py +138 -0
  136. openedx_content/migrations/0003_rename_content_to_media.py +51 -0
  137. openedx_content/migrations/0004_componenttype_constraint.py +49 -0
  138. openedx_content/migrations/0005_containertypes.py +94 -0
  139. openedx_content/migrations/0006_typed_ids.py +29 -0
  140. openedx_content/migrations/0007_publishlogrecord_direct.py +32 -0
  141. openedx_content/migrations/0008_rename_collection_key_to_collection_code.py +72 -0
  142. openedx_content/migrations/0009_rename_component_local_key_to_component_code.py +81 -0
  143. openedx_content/migrations/0010_add_container_code.py +104 -0
  144. openedx_content/migrations/0011_rename_entity_key_and_package_key_to_refs.py +84 -0
  145. openedx_content/migrations/0012_rename_componentversionmedia_key_to_path.py +46 -0
  146. openedx_content/migrations/0013_unicode_container_component_codes.py +60 -0
  147. openedx_content/migrations/0014_typed_media_id.py +28 -0
  148. openedx_content/migrations/__init__.py +0 -0
  149. openedx_content/models.py +18 -0
  150. openedx_content/models_api.py +18 -0
  151. openedx_content/py.typed +2 -0
  152. openedx_content/settings_api.py +38 -0
  153. openedx_content/signals.py +20 -0
  154. openedx_core/__init__.py +10 -0
  155. openedx_core-1.4.1.dist-info/METADATA +137 -0
  156. openedx_core-1.4.1.dist-info/RECORD +244 -0
  157. openedx_core-1.4.1.dist-info/WHEEL +5 -0
  158. openedx_core-1.4.1.dist-info/licenses/LICENSE.txt +674 -0
  159. openedx_core-1.4.1.dist-info/top_level.txt +6 -0
  160. openedx_django_lib/README.rst +9 -0
  161. openedx_django_lib/__init__.py +6 -0
  162. openedx_django_lib/admin_utils.py +116 -0
  163. openedx_django_lib/collations.py +65 -0
  164. openedx_django_lib/fields.py +282 -0
  165. openedx_django_lib/id_fields.py +57 -0
  166. openedx_django_lib/id_fields.pyi +17 -0
  167. openedx_django_lib/managers.py +48 -0
  168. openedx_django_lib/py.typed +2 -0
  169. openedx_django_lib/validators.py +15 -0
  170. openedx_learning/README.rst +11 -0
  171. openedx_learning/__init__.py +3 -0
  172. openedx_learning/admin.py +6 -0
  173. openedx_learning/api.py +6 -0
  174. openedx_learning/applets/__init__.py +3 -0
  175. openedx_learning/applets/cbe/README.rst +21 -0
  176. openedx_learning/applets/cbe/__init__.py +3 -0
  177. openedx_learning/applets/cbe/admin.py +21 -0
  178. openedx_learning/applets/cbe/api.py +65 -0
  179. openedx_learning/applets/cbe/models.py +40 -0
  180. openedx_learning/apps.py +15 -0
  181. openedx_learning/migrations/0001_initial.py +27 -0
  182. openedx_learning/migrations/__init__.py +0 -0
  183. openedx_learning/models.py +7 -0
  184. openedx_learning/models_api.py +9 -0
  185. openedx_learning/py.typed +0 -0
  186. openedx_tagging/__init__.py +0 -0
  187. openedx_tagging/admin.py +44 -0
  188. openedx_tagging/api.py +573 -0
  189. openedx_tagging/apps.py +23 -0
  190. openedx_tagging/data.py +39 -0
  191. openedx_tagging/import_export/__init__.py +4 -0
  192. openedx_tagging/import_export/actions.py +451 -0
  193. openedx_tagging/import_export/api.py +231 -0
  194. openedx_tagging/import_export/exceptions.py +133 -0
  195. openedx_tagging/import_export/import_plan.py +218 -0
  196. openedx_tagging/import_export/parsers.py +351 -0
  197. openedx_tagging/import_export/tasks.py +43 -0
  198. openedx_tagging/import_export/template.csv +30 -0
  199. openedx_tagging/import_export/template.json +158 -0
  200. openedx_tagging/migrations/0001_initial.py +212 -0
  201. openedx_tagging/migrations/0001_squashed.py +152 -0
  202. openedx_tagging/migrations/0002_auto_20230718_2026.py +79 -0
  203. openedx_tagging/migrations/0003_auto_20230721_1238.py +76 -0
  204. openedx_tagging/migrations/0004_auto_20230723_2001.py +35 -0
  205. openedx_tagging/migrations/0005_language_taxonomy.py +31 -0
  206. openedx_tagging/migrations/0006_alter_objecttag_unique_together.py +16 -0
  207. openedx_tagging/migrations/0006_auto_20230802_1631.py +82 -0
  208. openedx_tagging/migrations/0007_tag_import_task_log_null_fix.py +19 -0
  209. openedx_tagging/migrations/0008_taxonomy_description_not_null.py +24 -0
  210. openedx_tagging/migrations/0009_alter_objecttag_object_id.py +20 -0
  211. openedx_tagging/migrations/0010_cleanups.py +32 -0
  212. openedx_tagging/migrations/0011_remove_required.py +22 -0
  213. openedx_tagging/migrations/0012_language_taxonomy.py +43 -0
  214. openedx_tagging/migrations/0013_tag_parent_blank.py +19 -0
  215. openedx_tagging/migrations/0014_minor_fixes.py +36 -0
  216. openedx_tagging/migrations/0015_taxonomy_export_id.py +38 -0
  217. openedx_tagging/migrations/0016_object_tag_export_id.py +62 -0
  218. openedx_tagging/migrations/0017_alter_tagimporttask_status.py +18 -0
  219. openedx_tagging/migrations/0018_objecttag_is_copied.py +18 -0
  220. openedx_tagging/migrations/0019_language_taxonomy_class.py +33 -0
  221. openedx_tagging/migrations/0020_tag_depth_and_lineage.py +163 -0
  222. openedx_tagging/migrations/0021_remove_system_defined_add_read_only.py +89 -0
  223. openedx_tagging/migrations/__init__.py +0 -0
  224. openedx_tagging/models/__init__.py +5 -0
  225. openedx_tagging/models/base.py +885 -0
  226. openedx_tagging/models/import_export.py +150 -0
  227. openedx_tagging/models/utils.py +13 -0
  228. openedx_tagging/py.typed +0 -0
  229. openedx_tagging/readme.rst +15 -0
  230. openedx_tagging/rest_api/__init__.py +0 -0
  231. openedx_tagging/rest_api/paginators.py +105 -0
  232. openedx_tagging/rest_api/urls.py +9 -0
  233. openedx_tagging/rest_api/utils.py +110 -0
  234. openedx_tagging/rest_api/v1/__init__.py +0 -0
  235. openedx_tagging/rest_api/v1/exception_handlers.py +58 -0
  236. openedx_tagging/rest_api/v1/permissions.py +68 -0
  237. openedx_tagging/rest_api/v1/serializers.py +476 -0
  238. openedx_tagging/rest_api/v1/urls.py +27 -0
  239. openedx_tagging/rest_api/v1/views.py +969 -0
  240. openedx_tagging/rest_api/v1/views_import.py +53 -0
  241. openedx_tagging/rules.py +210 -0
  242. openedx_tagging/signal_handlers.py +119 -0
  243. openedx_tagging/tasks.py +82 -0
  244. openedx_tagging/urls.py +10 -0
@@ -0,0 +1,36 @@
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
+ Content -- "References (see ADR 0001)" --> Catalog
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_learning: pathways**</a> (Pathway definition, PathwayItem, PathwayCertificate, etc. The unversioned CatalogPathway lives in openedx_catalog.)"]
28
+ Pathways -- References --> Catalog
29
+ Pathways -- References --> Content
30
+
31
+ style Pathways fill:#c0ffee,stroke-dasharray: 5 5
32
+
33
+ FutureCatalog["Future discovery service - learner-oriented, pluggable, browse/search courses and programs"] -- References --> Catalog
34
+ FutureCatalog <-- Plugin API --> Pathways
35
+ style FutureCatalog fill:#ffc0ee,stroke-dasharray: 5 5
36
+ ```
@@ -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 Open edX 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. To be independent of how and where content is stored: the catalog is not aware of content, and the mapping from a course run to its content (in modulestore or in ``openedx_content``) is maintained outside this app. See `ADR 0001 <../../docs/openedx_catalog/decisions/0001-catalog-models-usage.rst>`__.
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.)
File without changes
@@ -0,0 +1,112 @@
1
+ """
2
+ Django Admin pages for openedx_catalog.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.contrib import admin
11
+ from django.db.models import Count, QuerySet
12
+ from django.urls import reverse
13
+ from django.utils.html import format_html
14
+ from django.utils.translation import gettext_lazy as _
15
+
16
+ from .models import CatalogCourse, CourseRun
17
+
18
+ if TYPE_CHECKING:
19
+
20
+ class CatalogCourseWithRunCount(CatalogCourse):
21
+ run_count: int
22
+
23
+
24
+ class CatalogCourseAdmin(admin.ModelAdmin):
25
+ """
26
+ The CatalogCourse model admin.
27
+ """
28
+
29
+ list_filter = ["org__short_name", "language"]
30
+ list_display = [
31
+ "title",
32
+ "org_display",
33
+ "course_code",
34
+ "runs_summary",
35
+ "key_str",
36
+ "created_date",
37
+ "language",
38
+ ]
39
+
40
+ def get_readonly_fields(self, request, obj: CatalogCourse | None = None) -> tuple[str, ...]:
41
+ if obj: # editing an existing object
42
+ return ("org", "course_code")
43
+ return tuple()
44
+
45
+ def get_queryset(self, request) -> QuerySet[CatalogCourseWithRunCount]:
46
+ """Add the 'run_count' to the list_display queryset"""
47
+ qs = super().get_queryset(request)
48
+ qs = qs.annotate(run_count=Count("runs"))
49
+ return qs
50
+
51
+ @admin.display(description="Organization", ordering="org__short_name")
52
+ def org_display(self, obj: CatalogCourse) -> str:
53
+ """Display the organization, only showing the short_name if different from full name"""
54
+ if obj.org.name == obj.org.short_name:
55
+ return obj.org.short_name
56
+ return str(obj.org)
57
+
58
+ @admin.display(description=_("Created"), ordering="created")
59
+ def created_date(self, obj: CatalogCourse) -> datetime.date:
60
+ """Display the created date without the timestamp"""
61
+ return obj.created.date()
62
+
63
+ @admin.display(description=_("Runs"))
64
+ def runs_summary(self, obj: CatalogCourseWithRunCount) -> str:
65
+ """Summarize the runs"""
66
+ if obj.run_count == 0:
67
+ return "-"
68
+ url = reverse("admin:openedx_catalog_courserun_changelist") + f"?catalog_course={obj.pk}"
69
+ first_few_runs = obj.runs.order_by("-run_code")[:3]
70
+ runs_summary = ", ".join(run.run_code for run in first_few_runs)
71
+ if obj.run_count > 4:
72
+ runs_summary += f", ... ({obj.run_count})"
73
+ return format_html('<a href="{}">{}</a>', url, runs_summary)
74
+
75
+
76
+ admin.site.register(CatalogCourse, CatalogCourseAdmin)
77
+
78
+
79
+ class CourseRunAdmin(admin.ModelAdmin):
80
+ """
81
+ The CourseRun model admin.
82
+ """
83
+
84
+ list_display = ["title", "created_date", "catalog_course", "org_code", "course_code", "run_code", "warnings"]
85
+ readonly_fields = ("course_key",)
86
+ # There may be thousands of catalog courses, so don't use <select>
87
+ raw_id_fields = ["catalog_course"]
88
+
89
+ def get_readonly_fields(self, request, obj: CourseRun | None = None):
90
+ if obj: # editing an existing object
91
+ return self.readonly_fields + ("run_code",)
92
+ return self.readonly_fields
93
+
94
+ @admin.display(description=_("Created"), ordering="created")
95
+ def created_date(self, obj: CourseRun) -> datetime.date:
96
+ """Display the created date without the timestamp"""
97
+ return obj.created.date()
98
+
99
+ def warnings(self, obj: CourseRun) -> str | None:
100
+ """Display warnings of any detected issues"""
101
+ if obj.course_code != obj.catalog_course.course_code:
102
+ return "🚨 Critical: mismatched course code"
103
+ if obj.org_code != obj.catalog_course.org.short_name:
104
+ if obj.org_code.lower() == obj.catalog_course.org.short_name.lower():
105
+ return "⚠️ Warning: Incorrect org code capitalization"
106
+ return "🚨 Critical: mismatched org code"
107
+ # It would be nice to indicate if there's associated course content or not, but openedx-core isn't aware of
108
+ # modulestore so we have no way to check that here.
109
+ return None
110
+
111
+
112
+ admin.site.register(CourseRun, CourseRunAdmin)
openedx_catalog/api.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Open edX Core Catalog API
3
+
4
+ Note: this is currently a very minimal API. At this point, the openedx_catalog
5
+ app mainly exists to provide core models that represent "catalog courses" and
6
+ "course runs" for use by foreign keys across the system.
7
+
8
+ If a course "exists" in the system, you can trust that it will exist as a
9
+ CatalogCourse and CourseRun row in this openedx_catalog app, and use those as
10
+ needed when creating foreign keys in various apps. This should be much more
11
+ efficient than storing the full course key as a string or creating a foreign key
12
+ to the (large) CourseOverview table.
13
+
14
+ Note that the opposite does not hold. Admins can now create CourseRuns and/or
15
+ CatalogCourses that don't yet have any content attached. So you may find entries
16
+ in this openedx_catalog app that don't correspond to courses in modulestore.
17
+
18
+ In addition, we currently do not account for which courses should be visible to
19
+ which users. So this API does not yet provide any "list courses" methods. In the
20
+ future, the catalog API will be extended to implement course listing along with
21
+ pluggable logic for managing multiple catalogs of courses that can account for
22
+ instance-specific logic (e.g. enterprise, subscriptions, white labelling) when
23
+ determining which courses are visible to which users.
24
+ """
25
+
26
+ # Import only the public API methods denoted with __all__
27
+ # pylint: disable=wildcard-import
28
+ from .api_impl import *
29
+
30
+ # You'll also want the models from .models_api
@@ -0,0 +1,251 @@
1
+ """
2
+ Implementation of the `openedx_catalog` API.
3
+ """
4
+
5
+ import logging
6
+ from typing import overload
7
+
8
+ from opaque_keys.edx.keys import CourseKey
9
+ from organizations.api import ensure_organization # type: ignore[import]
10
+ from organizations.api import exceptions as org_exceptions
11
+
12
+ from .models import CatalogCourse, CourseRun
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # These are the public API methods that anyone can use
17
+ __all__ = [
18
+ "get_catalog_course",
19
+ "update_catalog_course",
20
+ "delete_catalog_course",
21
+ "get_course_run",
22
+ "sync_course_run_details",
23
+ "create_course_run_for_modulestore_course_with",
24
+ "delete_course_run",
25
+ ]
26
+
27
+
28
+ @overload
29
+ def get_catalog_course(*, org_code: str, course_code: str) -> CatalogCourse: ...
30
+ @overload
31
+ def get_catalog_course(*, key_str: str) -> CatalogCourse: ...
32
+ @overload
33
+ def get_catalog_course(*, pk: CatalogCourse.ID) -> CatalogCourse: ...
34
+
35
+
36
+ def get_catalog_course(
37
+ pk: CatalogCourse.ID | None = None,
38
+ key_str: str = "",
39
+ org_code: str = "",
40
+ course_code: str = "",
41
+ ) -> CatalogCourse:
42
+ """
43
+ Get a catalog course (set of runs).
44
+
45
+ ⚠️ Does not check permissions or visibility rules.
46
+
47
+ The CatalogCourse may not have any runs associated with it.
48
+ """
49
+ assert pk or key_str or (org_code and course_code)
50
+ if pk:
51
+ assert not org_code
52
+ assert not key_str
53
+ return CatalogCourse.objects.get(pk=pk)
54
+ if key_str:
55
+ assert key_str.startswith("catalog-course:")
56
+ assert not org_code
57
+ assert not course_code
58
+ _, org_code, course_code = key_str.split(":", 2)
59
+ # We might as well select_related org because we're joining to check the org__short_name field anyways.
60
+ return CatalogCourse.objects.select_related("org").get(org__short_name=org_code, course_code=course_code)
61
+
62
+
63
+ def update_catalog_course(
64
+ catalog_course: CatalogCourse | CatalogCourse.ID,
65
+ *,
66
+ title: str | None = None, # Specify a string to change the title (display name).
67
+ # The short language code (one of settings.ALL_LANGUAGES), e.g. "en", "es", "zh_HANS"
68
+ language_short: str | None = None,
69
+ ) -> None:
70
+ """
71
+ Update a `CatalogCourse`.
72
+
73
+ ⚠️ Does not check permissions.
74
+ """
75
+ if isinstance(catalog_course, CatalogCourse):
76
+ cc = catalog_course
77
+ else:
78
+ cc = CatalogCourse.objects.get(pk=catalog_course)
79
+
80
+ update_fields = []
81
+ if title:
82
+ cc.title = title
83
+ update_fields.append("title")
84
+ if language_short:
85
+ cc.language_short = language_short
86
+ update_fields.append("language")
87
+ if update_fields:
88
+ cc.save(update_fields=update_fields)
89
+
90
+
91
+ def delete_catalog_course(catalog_course: CatalogCourse | CatalogCourse.ID) -> None:
92
+ """
93
+ Delete a `CatalogCourse`. This will fail with a `ProtectedError` if any runs exist.
94
+
95
+ ⚠️ Does not check permissions.
96
+ ⚠️ Does not emit any course lifecycle events.
97
+ """
98
+ if isinstance(catalog_course, CatalogCourse):
99
+ cc = catalog_course
100
+ else:
101
+ cc = CatalogCourse.objects.get(pk=catalog_course)
102
+ cc.delete()
103
+
104
+
105
+ def get_course_run(course_key: CourseKey) -> CourseRun:
106
+ """
107
+ Get a single course run.
108
+
109
+ ⚠️ Does not check permissions or visibility rules.
110
+
111
+ The CourseRun may or may not have content associated with it.
112
+
113
+ Tip: to get all runs associated with a CatalogCourse, use
114
+ `get_catalog_course(...).runs`
115
+ """
116
+ return CourseRun.objects.get(course_key__exact=course_key)
117
+
118
+
119
+ def sync_course_run_details(
120
+ course_key: CourseKey,
121
+ *,
122
+ title: str | None, # Specify a string to change the title (display name).
123
+ ) -> None:
124
+ """
125
+ Update a `CourseRun` with details from a more authoritative model (e.g.
126
+ `CourseOverview`). Currently the only field that can be updated is
127
+ `title` (display name).
128
+
129
+ The name of this function reflects the fact that the `CourseRun` model is
130
+ not currently a source of truth. So it's not a "rename the course" API, but
131
+ rather a "some other part of the system already renamed the course" API,
132
+ during a transition period until `CourseRun` is the main source of truth.
133
+
134
+ Once `CourseRun` is the main source of truth, this will be replaced with a
135
+ `update_course_run` API that will become the main way to rename a course.
136
+
137
+ ⚠️ Does not check permissions.
138
+ ⚠️ Does not emit any course lifecycle events.
139
+ """
140
+ run = CourseRun.objects.get(course_key=course_key)
141
+ if title:
142
+ run.title = title
143
+ run.save(update_fields=["title"])
144
+
145
+
146
+ def create_course_run_for_modulestore_course_with(
147
+ course_key: CourseKey,
148
+ *,
149
+ title: str,
150
+ # The short language code (in openedx-platform, this is one of settings.ALL_LANGUAGES), e.g. "en", "es", "zh_HANS"
151
+ language_short: str | None = None,
152
+ ) -> CourseRun:
153
+ """
154
+ Create a `CourseRun` (and, if necessary, its corresponding `CatalogCourse`).
155
+ This API is meant to be used for data synchonrization purposes (keeping the
156
+ new catalog models in sync with modulestore), and is not a generic "create a
157
+ course run" API.
158
+
159
+ If the `CourseRun` already exists, this will log a warning.
160
+
161
+ The `created` timestamp of the `CourseRun` will be set to now, so this is
162
+ not meant for historical data (use a data migration).
163
+
164
+ ⚠️ Does not check permissions.
165
+ ⚠️ Does not emit any course lifecycle events.
166
+ """
167
+ # Note: this code shares a lot with the code in
168
+ # openedx-platform/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_...
169
+ # but migrations should generally represent a point-in-time transformation, not call an API method that may continue
170
+ # to be developed. So even though it's not DRY, the code is repeated here.
171
+
172
+ org_code = course_key.org
173
+ course_code = course_key.course
174
+ try:
175
+ cc = CatalogCourse.objects.get(org__short_name=org_code, course_code=course_code)
176
+ except CatalogCourse.DoesNotExist:
177
+ cc = None
178
+
179
+ if not cc:
180
+ # Create the catalog course.
181
+
182
+ # First, ensure that the Organization exists.
183
+ try:
184
+ org_data = ensure_organization(org_code)
185
+ except org_exceptions.InvalidOrganizationException as exc:
186
+ # Note: IFF the org exists among the modulestore courses but not in the Organizations database table,
187
+ # and if auto-create is disabled (it's enabled by default), this will raise InvalidOrganizationException. It
188
+ # would be up to the operator to decide how they want to resolve that.
189
+ raise ValueError(
190
+ f'The organization short code "{org_code}" exists in modulestore ({str(course_key)}) but '
191
+ "not the Organizations table, and auto-creating organizations is disabled. You can resolve this by "
192
+ "creating the Organization manually (e.g. from the Django admin) or turning on auto-creation. "
193
+ "You can set active=False to prevent this Organization from being used other than for historical data. "
194
+ ) from exc
195
+ if org_data["short_name"] != org_code:
196
+ # On most installations, the 'short_name' database column is case insensitive (unfortunately)
197
+ log.warning(
198
+ 'The course with ID "%s" does not match its Organization.short_name "%s"',
199
+ str(course_key),
200
+ org_data["short_name"],
201
+ )
202
+
203
+ # Actually create the CatalogCourse. We use get_or_create just to be extra robust against race conditions, since
204
+ # we don't care if another worker/thread/etc has beaten us to creating this.
205
+ cc, _cc_created = CatalogCourse.objects.get_or_create(
206
+ org_id=org_data["id"],
207
+ course_code=course_code,
208
+ defaults={
209
+ "title": title,
210
+ **({"language_short": language_short} if language_short else {}),
211
+ },
212
+ )
213
+
214
+ new_run, created = CourseRun.objects.get_or_create(
215
+ catalog_course=cc,
216
+ run_code=course_key.run,
217
+ course_key=course_key,
218
+ defaults={"title": title},
219
+ )
220
+
221
+ if not created:
222
+ log.warning('Expected to create CourseRun for "%s" but it already existed.', str(course_key))
223
+
224
+ return new_run
225
+
226
+
227
+ def delete_course_run(course_key: CourseKey) -> None:
228
+ """
229
+ Delete a `CourseRun`.
230
+
231
+ For now, this method is only useful for keeping the `CourseRun` data in sync
232
+ with other models like `CourseOverview` that are used as a source of truth.
233
+ Calling this method will not yet affect most parts of the system, so you
234
+ should only use this if the course run is a "placeholder" course that has no
235
+ content yet, or the course has already been deleted in the other parts of
236
+ the platform (e.g. modulestore). In the future, we will invert this
237
+ dependency, and calling this _would_ cascade to delete `CourseOverview`, and
238
+ perhaps other records as well.
239
+
240
+ (This method does not delete content, if any content is associated with the
241
+ run, and that is not expected to change. In the future, a separate API
242
+ method may implement "delete course + content + (optionally) enrollments +
243
+ student state + etc.".)
244
+
245
+ This may fail with a `ProtectedError` or other `IntegrityError` subclass if
246
+ there are still active references to the course run.
247
+
248
+ ⚠️ Does not check permissions.
249
+ ⚠️ Does not emit any course lifecycle events.
250
+ """
251
+ CourseRun.objects.get(course_key=course_key).delete()
@@ -0,0 +1,21 @@
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
+
20
+ # Connect signal handlers.
21
+ from . import signals # pylint: disable=import-outside-toplevel,unused-import