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,209 @@
1
+ # Generated by Django 5.2.11 on 2026-02-10 03:08
2
+
3
+ import django.db.models.deletion
4
+ import django.db.models.functions.text
5
+ import django.db.models.lookups
6
+ import opaque_keys.edx.django.models
7
+ from django.db import migrations, models
8
+
9
+ import openedx_catalog.models.catalog_course
10
+ import openedx_django_lib.fields
11
+ import openedx_django_lib.validators
12
+
13
+
14
+ class Migration(migrations.Migration):
15
+ initial = True
16
+
17
+ dependencies = [
18
+ ("organizations", "0004_auto_20230727_2054"),
19
+ ]
20
+
21
+ operations = [
22
+ migrations.CreateModel(
23
+ name="CatalogCourse",
24
+ fields=[
25
+ (
26
+ "id",
27
+ models.BigAutoField(
28
+ editable=False,
29
+ help_text="The internal database ID for this catalog course. Should not be exposed to users nor in APIs.",
30
+ primary_key=True,
31
+ serialize=False,
32
+ verbose_name="Primary Key",
33
+ ),
34
+ ),
35
+ (
36
+ "course_code",
37
+ openedx_django_lib.fields.MultiCollationCharField(
38
+ db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
39
+ help_text='The course code/number, e.g. "Math100".',
40
+ max_length=255,
41
+ ),
42
+ ),
43
+ (
44
+ "created",
45
+ models.DateTimeField(
46
+ auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime]
47
+ ),
48
+ ),
49
+ (
50
+ "title",
51
+ openedx_django_lib.fields.MultiCollationCharField(
52
+ db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"},
53
+ help_text='The full title (display name) of this catalog course. e.g. "Introduction to Calculus". Individual course runs may override this, e.g. "Intro to Calc (Fall 2026 with Dr. Newton)".',
54
+ max_length=255,
55
+ ),
56
+ ),
57
+ (
58
+ "language",
59
+ openedx_django_lib.fields.MultiCollationCharField(
60
+ db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
61
+ default=openedx_catalog.models.catalog_course.get_default_language_code,
62
+ help_text='The code representing the primary language of this catalog course\'s content - the language in which the content is authored and which learners can learn without translation. (Translated versions of the course or parts of the course may be available in other languages if supported by the platform.) The first two digits must be the lowercase ISO 639-1 language code, optionally followed by a country/locale code. e.g. "en", "es", "fr-ca", "pt-br", "zh-cn", "zh-hk". ',
63
+ max_length=64,
64
+ ),
65
+ ),
66
+ (
67
+ "org",
68
+ models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="organizations.organization"),
69
+ ),
70
+ ],
71
+ options={
72
+ "verbose_name": "Catalog Course",
73
+ "verbose_name_plural": "Catalog Courses",
74
+ "ordering": ("-created",),
75
+ },
76
+ ),
77
+ migrations.CreateModel(
78
+ name="CourseRun",
79
+ fields=[
80
+ (
81
+ "id",
82
+ models.BigAutoField(
83
+ editable=False,
84
+ help_text="The internal database ID for this course. Should not be exposed to users nor in APIs.",
85
+ primary_key=True,
86
+ serialize=False,
87
+ verbose_name="Primary Key",
88
+ ),
89
+ ),
90
+ (
91
+ "course_key",
92
+ opaque_keys.edx.django.models.CourseKeyField(
93
+ case_sensitive=True,
94
+ db_index=True,
95
+ editable=False,
96
+ help_text="The main identifier for this course. Includes the org, course code, and run.",
97
+ max_length=255,
98
+ verbose_name="Course Key",
99
+ ),
100
+ ),
101
+ (
102
+ "run_code",
103
+ openedx_django_lib.fields.MultiCollationCharField(
104
+ db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
105
+ help_text='The code that identifies this particular run of the course, e.g. "2026", "2026Fall" or "2T2026"',
106
+ max_length=128,
107
+ ),
108
+ ),
109
+ (
110
+ "created",
111
+ models.DateTimeField(
112
+ auto_now_add=True, validators=[openedx_django_lib.validators.validate_utc_datetime]
113
+ ),
114
+ ),
115
+ (
116
+ "title",
117
+ openedx_django_lib.fields.MultiCollationCharField(
118
+ blank=True,
119
+ db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"},
120
+ help_text='The full title (display name) of this course. e.g. "Introduction to Calculus". This is required and will override the title of the catalog course. Leave blank to use the same title as the catalog course. ',
121
+ max_length=255,
122
+ ),
123
+ ),
124
+ (
125
+ "catalog_course",
126
+ models.ForeignKey(
127
+ on_delete=django.db.models.deletion.PROTECT,
128
+ related_name="runs",
129
+ to="openedx_catalog.catalogcourse",
130
+ ),
131
+ ),
132
+ ],
133
+ options={
134
+ "verbose_name": "Course Run",
135
+ "verbose_name_plural": "Course Runs",
136
+ "ordering": ("-created",),
137
+ },
138
+ ),
139
+ migrations.AddIndex(
140
+ model_name="catalogcourse",
141
+ index=models.Index(fields=["org", "course_code"], name="openedx_cat_org_id_bd314b_idx"),
142
+ ),
143
+ migrations.AddConstraint(
144
+ model_name="catalogcourse",
145
+ constraint=models.UniqueConstraint(
146
+ models.F("org"),
147
+ django.db.models.functions.text.Lower("course_code"),
148
+ name="oex_catalog_catalogcourse_org_code_uniq_ci",
149
+ ),
150
+ ),
151
+ migrations.AddConstraint(
152
+ model_name="catalogcourse",
153
+ constraint=models.CheckConstraint(
154
+ condition=models.Q(("course_code__length__gt", 0)),
155
+ name="oex_catalog_catalogcourse_course_code_not_blank",
156
+ ),
157
+ ),
158
+ migrations.AddConstraint(
159
+ model_name="catalogcourse",
160
+ constraint=models.CheckConstraint(
161
+ condition=models.Q(("title__length__gt", 0)), name="oex_catalog_catalogcourse_title_not_blank"
162
+ ),
163
+ ),
164
+ migrations.AddConstraint(
165
+ model_name="catalogcourse",
166
+ constraint=models.CheckConstraint(
167
+ condition=django.db.models.lookups.Regex(models.F("language"), "^[a-z][a-z](\\-[a-z0-9]+)*$"),
168
+ name="oex_catalog_catalogcourse_language_regex",
169
+ violation_error_message='The language code must be lowercase, e.g. "en". If a country/locale code is provided, it must be separated by a hyphen, e.g. "en-us", "zh-hk". ',
170
+ ),
171
+ ),
172
+ migrations.AddConstraint(
173
+ model_name="courserun",
174
+ constraint=models.UniqueConstraint(
175
+ models.F("catalog_course"),
176
+ models.F("run_code"),
177
+ condition=models.Q(("course_key__startswith", "ccx"), _negated=True),
178
+ name="oex_catalog_courserun_catalog_course_run_code_uniq",
179
+ ),
180
+ ),
181
+ migrations.AddConstraint(
182
+ model_name="courserun",
183
+ constraint=models.UniqueConstraint(
184
+ django.db.models.functions.text.Lower("course_key"), name="oex_catalog_courserun_course_key_ci"
185
+ ),
186
+ ),
187
+ migrations.AddConstraint(
188
+ model_name="courserun",
189
+ constraint=models.CheckConstraint(
190
+ condition=models.Q(("run_code__length__gt", 0)), name="oex_catalog_courserun_run_code_not_blank"
191
+ ),
192
+ ),
193
+ migrations.AddConstraint(
194
+ model_name="courserun",
195
+ constraint=models.CheckConstraint(
196
+ condition=models.Q(("title__length__gt", 0)), name="oex_catalog_courserun_title_not_blank"
197
+ ),
198
+ ),
199
+ migrations.AddConstraint(
200
+ model_name="courserun",
201
+ constraint=models.CheckConstraint(
202
+ condition=django.db.models.lookups.GreaterThan(
203
+ django.db.models.functions.text.StrIndex("course_key", models.F("run_code")), 0
204
+ ),
205
+ name="oex_catalog_courserun_course_key_run_code_match_exactly",
206
+ violation_error_message="The CourseRun 'run_code' field should match the run in the 'course_key'.",
207
+ ),
208
+ ),
209
+ ]
File without changes
@@ -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,0 +1,246 @@
1
+ """
2
+ CatalogCourse model
3
+ """
4
+
5
+ import logging
6
+ from typing import NewType
7
+
8
+ from django.conf import settings
9
+ from django.contrib import admin
10
+ from django.db import models
11
+ from django.db.models.functions import Length, Lower
12
+ from django.db.models.lookups import Regex
13
+ from django.utils.translation import gettext_lazy as _
14
+ from organizations.models import Organization # type: ignore[import]
15
+
16
+ from openedx_django_lib.fields import TypedBigAutoField, case_insensitive_char_field, case_sensitive_char_field
17
+ from openedx_django_lib.validators import validate_utc_datetime
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def get_default_language_code() -> str:
23
+ """
24
+ Default language code used for CatalogCourse.language
25
+
26
+ Note: this function is used in migration 0001 so update that migration if
27
+ moving it or changing its signature.
28
+ """
29
+ return settings.LANGUAGE_CODE # e.g. "en-us", "fr-ca"
30
+
31
+
32
+ # Make 'length' available for CHECK constraints. OK if this is called multiple times.
33
+ models.CharField.register_lookup(Length)
34
+
35
+
36
+ class CatalogCourse(models.Model):
37
+ """
38
+ A **catalog course** is a set of course runs.
39
+
40
+ For example, the "Math 100" catalog course is comprised of one or more
41
+ "course runs" (e.g. "Math 100 2025Fall", "Math 100 2026Spring").
42
+
43
+ Note that most parts of the Open edX system only need to know/care about
44
+ course runs, not catalog courses, but this package nevertheless provides the
45
+ CatalogCourse model as a foundation for the few use cases that require it.
46
+
47
+ ⚠️ `CatalogCourse`s and `CourseRun`s may exist as marketing/enrollment
48
+ placeholders for courses that do not yet otherwise exist (do not yet have
49
+ content). So in general, you should not assume that any related models /
50
+ content exist just because the `CatalogCourse` or `CourseRun` exists.
51
+
52
+ A `CatalogCourse` may exist without any `CourseRun`s, but a `CourseRun`
53
+ cannot exist without a `CatalogCourse`, and this is enforced by the
54
+ database.
55
+
56
+ 🔍 This model is intentionally as minimal as possible. For anything else you
57
+ may want to do that involves new fields, please add them via a related model
58
+ in your own app, and make a `ForeignKey` or `OneToOneField` relationship to
59
+ this model. (Unless you really think it's something core that all catalog
60
+ courses in all instances of Open edX will need.)
61
+ """
62
+
63
+ CatalogCourseID = NewType("CatalogCourseID", int)
64
+ type ID = CatalogCourseID
65
+
66
+ class IDField(TypedBigAutoField[ID]): # Boilerplate for fully-typed ID field.
67
+ pass
68
+
69
+ id = IDField(
70
+ primary_key=True,
71
+ verbose_name=_("Primary Key"),
72
+ help_text=_("The internal database ID for this catalog course. Should not be exposed to users nor in APIs."),
73
+ editable=False,
74
+ )
75
+ org = models.ForeignKey(
76
+ Organization,
77
+ on_delete=models.PROTECT,
78
+ null=False,
79
+ # Initially, I had to_field="short_name" here, which has the nice property that we can look up an org's
80
+ # short_name without doing a JOIN. But that also prevents changing the org's short_name, which could be
81
+ # necessary to fix capitalization problems. (We wouldn't want to allow other changes to an org's short_name
82
+ # though; only fixing capitalization - see openedx_catalog.signals.verify_organization_change.)
83
+ )
84
+ course_code = case_sensitive_char_field(
85
+ max_length=255,
86
+ blank=False,
87
+ null=False,
88
+ help_text=_('The course code/number, e.g. "Math100".'),
89
+ )
90
+ # When this catalog course was first created. We don't track "modified" as this model should be basically immutable,
91
+ # and the only field that may ever change is "title"; we also don't want people to think that the "modified" time
92
+ # reflects when other related models/data was updated.
93
+ created = models.DateTimeField(
94
+ auto_now_add=True,
95
+ validators=[validate_utc_datetime],
96
+ editable=False,
97
+ )
98
+ title = case_insensitive_char_field(
99
+ max_length=255,
100
+ blank=False,
101
+ help_text=_(
102
+ 'The full title (display name) of this catalog course. e.g. "Introduction to Calculus". '
103
+ 'Individual course runs may override this, e.g. "Intro to Calc (Fall 2026 with Dr. Newton)".'
104
+ ),
105
+ )
106
+ # Note: language codes used on the Open edX platform are inconsistent.
107
+ # See https://github.com/openedx/openedx-platform/issues/38036
108
+ # For this model going forward, we normalized them to match settings.LANGUAGES (en, fr-ca, zh-cn, zh-hk) but for
109
+ # backwards compatibility, you can get/set the language_short field which uses the mostly two-letter values from
110
+ # the platform's ALL_LANGUAGES setting (en, fr, es, zh_HANS, zh_HANT).
111
+ language = case_sensitive_char_field( # Case sensitive but constraints force it to be lowercase.
112
+ max_length=64,
113
+ blank=False,
114
+ null=False,
115
+ default=get_default_language_code,
116
+ help_text=_(
117
+ "The code representing the primary language of this catalog course's content - the language in which the "
118
+ "content is authored and which learners can learn without translation. (Translated versions of the course "
119
+ "or parts of the course may be available in other languages if supported by the platform.) "
120
+ "The first two digits must be the lowercase ISO 639-1 language code, optionally followed by a "
121
+ 'country/locale code. e.g. "en", "es", "fr-ca", "pt-br", "zh-cn", "zh-hk". '
122
+ ),
123
+ )
124
+
125
+ # 🛑 Avoid adding additional fields here. The core Open edX platform doesn't do much with catalog courses, so
126
+ # we don't need "description", "visibility", "prereqs", or anything else. If you want to use such fields and
127
+ # expose them to users, you'll need to supplement this with additional data/models as mentioned in the docstring.
128
+
129
+ @property
130
+ def language_short(self) -> str:
131
+ """
132
+ Get the language code used by this catalog course, without locale.
133
+ This is always a two-digit code, except for Mandarin and Cantonese.
134
+ (This should be a value from settings.ALL_LANGUAGES, and should match
135
+ the CourseOverview.language field.)
136
+ """
137
+ if self.language == "zh-cn": # Chinese (Mainland China)
138
+ return "zh_HANS" # Mandarin / Simplified
139
+ elif self.language == "zh-hk": # Chinese (Hong Kong)
140
+ return "zh_HANT" # Cantonese / Traditional
141
+ return self.language[:2] # Strip locale
142
+
143
+ @language_short.setter
144
+ def language_short(self, legacy_code: str) -> None:
145
+ """
146
+ Set the language code used by this catalog course, without locale.
147
+ This is always a two-digit code, except for Mandarin and Cantonese.
148
+ (This should be a value from settings.ALL_LANGUAGES, and should match
149
+ the CourseOverview.language field.)
150
+ """
151
+ if hasattr(settings, "ALL_LANGUAGES"):
152
+ assert legacy_code in [code for (code, _name) in settings.ALL_LANGUAGES] # type: ignore
153
+ if legacy_code == "zh_HANS": # Mandarin / Simplified
154
+ self.language = "zh-cn" # Chinese (Mainland China)
155
+ elif legacy_code == "zh_HANT": # Cantonese / Traditional
156
+ self.language = "zh-hk" # Chinese (Hong Kong)
157
+ else:
158
+ self.language = legacy_code
159
+
160
+ @property
161
+ @admin.display(ordering="org__short_name")
162
+ def org_code(self) -> str:
163
+ """
164
+ Get the org code (Organization short_name) of this course, e.g. "MITx"
165
+
166
+ ⚠️ This is the org's canonical short_name and may differ in case from
167
+ the actual org code used in the course keys of the runs of this course,
168
+ especially with older catalog courses.
169
+ """
170
+ return self.org.short_name
171
+
172
+ @org_code.setter
173
+ def org_code(self, org_code: str) -> None:
174
+ """
175
+ Convenience method to set the related organization using its short_name.
176
+ """
177
+ # We don't use the Organizations API `get_organization_by_short_name`
178
+ # method because it filters for only active organizations.
179
+ # To support historical data, backfilling, etc., we need to allow inactive orgs here.
180
+ self.org = Organization.objects.get(short_name__iexact=org_code)
181
+ # Note: on SQLite, it's possible for multiple orgs with the same short_name to exist,
182
+ # and the above query could raise `Organization.MultipleObjectsReturned`, but it's
183
+ # not possible on MySQL, using the default collation used by the Organizations app.
184
+ # So we do not worry about that possibility here.
185
+
186
+ @property
187
+ def key_str(self) -> str:
188
+ """
189
+ A string key that can be used to identify this catalog course in URLs or
190
+ APIs.
191
+ In the future, this may be based on an editable SlugField, so don't
192
+ assume that it never changes. We may also make this an opaque key, in
193
+ which case you'd get it via `.key` and this `.key_str` would be
194
+ deprecated. The format returned by this function is designed to be
195
+ implementable as an opaque key.
196
+ """
197
+ return f"catalog-course:{self.org_code}:{self.course_code}"
198
+
199
+ def clean(self) -> None:
200
+ """Validate/normalize fields when edited via Django admin"""
201
+ # Set a default value for title:
202
+ if not self.title:
203
+ self.title = self.course_code
204
+ # Normalize language codes to match settings.LANGUAGES.
205
+ # It's safe to assume language is lowercase here, because if it's not the DB will reject its CHECK constraint.
206
+ if self.language == "zh-hans":
207
+ self.language = "zh-cn"
208
+ if self.language == "zh-hant":
209
+ self.language = "zh-hk"
210
+
211
+ def save(self, *args, **kwargs):
212
+ """Save the model, with some defaults and validation."""
213
+ self.clean()
214
+ super().save(*args, **kwargs)
215
+
216
+ def __str__(self) -> str:
217
+ return f"{self.title} ({self.org_code} {self.course_code})"
218
+
219
+ class Meta:
220
+ verbose_name = _("Catalog Course")
221
+ verbose_name_plural = _("Catalog Courses")
222
+ ordering = ("-created",)
223
+ indexes = [
224
+ # We need fast lookups by (org, course_code) pairs. We generally want this lookup to be case sensitive.
225
+ models.Index(fields=["org", "course_code"]),
226
+ ]
227
+ constraints = [
228
+ # The course_code must be case-insensitively unique per org:
229
+ models.UniqueConstraint("org", Lower("course_code"), name="oex_catalog_catalogcourse_org_code_uniq_ci"),
230
+ # Enforce at the DB level that these required fields are not blank:
231
+ models.CheckConstraint(
232
+ condition=models.Q(course_code__length__gt=0), name="oex_catalog_catalogcourse_course_code_not_blank"
233
+ ),
234
+ models.CheckConstraint(
235
+ condition=models.Q(title__length__gt=0), name="oex_catalog_catalogcourse_title_not_blank"
236
+ ),
237
+ # Language code must be lowercase, and locale codes separated by "-" (django convention) not "_"
238
+ models.CheckConstraint(
239
+ condition=Regex(models.F("language"), r"^[a-z][a-z](\-[a-z0-9]+)*$"),
240
+ name="oex_catalog_catalogcourse_language_regex",
241
+ violation_error_message=_(
242
+ 'The language code must be lowercase, e.g. "en". If a country/locale code is provided, '
243
+ 'it must be separated by a hyphen, e.g. "en-us", "zh-hk". '
244
+ ),
245
+ ),
246
+ ]