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,150 @@
1
+ """
2
+ Models used by the Taxonomy import/export tasks.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from django.db import models
8
+ from django.utils.translation import gettext as _
9
+ from django.utils.translation import gettext_lazy
10
+
11
+ from .base import Taxonomy
12
+
13
+
14
+ class TagImportTaskState(models.TextChoices):
15
+ """
16
+ Enumerates the states that a TagImportTask can be in.
17
+ """
18
+ LOADING_DATA = "loading_data", gettext_lazy("Loading Data")
19
+ PLANNING = "planning", gettext_lazy("Planning")
20
+ EXECUTING = "executing", gettext_lazy("Executing")
21
+ SUCCESS = "success", gettext_lazy("Success")
22
+ ERROR = "error", gettext_lazy("Error")
23
+
24
+
25
+ class TagImportTask(models.Model):
26
+ """
27
+ Stores the state, plan and logs of a tag import task
28
+ """
29
+
30
+ id = models.BigAutoField(primary_key=True)
31
+
32
+ taxonomy = models.ForeignKey(
33
+ "Taxonomy",
34
+ on_delete=models.CASCADE,
35
+ help_text=_("Taxonomy associated with this import"),
36
+ )
37
+
38
+ log = models.TextField(
39
+ blank=True, default=None, help_text=gettext_lazy("Action execution logs")
40
+ )
41
+
42
+ status = models.CharField(
43
+ max_length=20,
44
+ choices=TagImportTaskState.choices,
45
+ help_text=gettext_lazy("Task status"),
46
+ )
47
+
48
+ creation_date = models.DateTimeField(auto_now_add=True)
49
+
50
+ class Meta:
51
+ indexes = [
52
+ models.Index(fields=["taxonomy", "-creation_date"]),
53
+ ]
54
+
55
+ @classmethod
56
+ def create(cls, taxonomy: Taxonomy):
57
+ """
58
+ Creates and logs a new TagImportTask.
59
+ """
60
+ task = cls(
61
+ taxonomy=taxonomy,
62
+ status=TagImportTaskState.LOADING_DATA.value,
63
+ log="",
64
+ )
65
+ task.add_log(_("Import task created"), save=False)
66
+ task.save()
67
+ return task
68
+
69
+ def add_log(self, message: str, save=True):
70
+ """
71
+ Appends a log message to the task.
72
+ """
73
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
74
+ log_message = f"[{timestamp}] {message}\n"
75
+ self.log += log_message
76
+ if save:
77
+ self.save()
78
+
79
+ def log_exception(self, exception: Exception):
80
+ """
81
+ Logs an exception and moves the task status to ERROR.
82
+ """
83
+ self.add_log(repr(exception), save=False)
84
+ self.status = TagImportTaskState.ERROR.value
85
+ self.save()
86
+
87
+ def log_parser_start(self):
88
+ """
89
+ Logs the parser start event.
90
+ """
91
+ self.add_log(_("Starting to load data from file"))
92
+
93
+ def log_parser_end(self, elapsed_time):
94
+ """
95
+ Logs the parser finished event.
96
+ """
97
+ self.add_log(_("Load data finished. Time elapsed: ") + str(elapsed_time) + _(" seconds"))
98
+
99
+ def handle_parser_errors(self, errors):
100
+ """
101
+ Handles parser errors by logging them and moving the task status to ERROR.
102
+ """
103
+ for error in errors:
104
+ self.add_log(f"{str(error)}", save=False)
105
+ self.status = TagImportTaskState.ERROR.value
106
+ self.save()
107
+
108
+ def log_start_planning(self):
109
+ """
110
+ Starts task planning with a log message, and moves the task status to PLANNING.
111
+ """
112
+ self.add_log(_("Starting plan actions"), save=False)
113
+ self.status = TagImportTaskState.PLANNING.value
114
+ self.save()
115
+
116
+ def log_plan(self, plan, elapsed_time):
117
+ """
118
+ Logs the task plan.
119
+ """
120
+ self.add_log(_("Plan finished. Time elapsed: ") + str(elapsed_time) + _(" seconds"))
121
+ plan_str = plan.plan()
122
+ self.log += f"\n{plan_str}\n"
123
+ self.save()
124
+
125
+ def handle_plan_errors(self):
126
+ """
127
+ Handles plan errors by moving the task status to ERROR.
128
+ """
129
+ # Error are logged with plan
130
+ self.status = TagImportTaskState.ERROR.value
131
+ self.save()
132
+
133
+ def log_start_execute(self):
134
+ """
135
+ Starts task execution with a log message, and moves the task status to EXECUTING.
136
+ """
137
+ self.add_log(_("Starting execute actions"), save=False)
138
+ self.status = TagImportTaskState.EXECUTING.value
139
+ self.save()
140
+
141
+ def log_end_execute(self, elapsed_time):
142
+ self.add_log(_("Execute actions finished. Time elapsed: ") + str(elapsed_time) + _(" seconds"))
143
+
144
+ def end_success(self, elapsed_time):
145
+ """
146
+ Completes task execution with a log message, and moves the task status to SUCCESS.
147
+ """
148
+ self.add_log(_("Execution finished. Total time elapsed: ") + str(elapsed_time) + _("seconds"), save=False)
149
+ self.status = TagImportTaskState.SUCCESS.value
150
+ self.save()
@@ -0,0 +1,13 @@
1
+ """
2
+ Utilities for tagging and taxonomy models
3
+ """
4
+
5
+ RESERVED_TAG_CHARS = [
6
+ '\t', # Used in the database to separate tag levels in the "lineage" field
7
+ # e.g. lineage="Earth\tNorth America\tMexico\tMexico City\t"
8
+ ' > ', # Used in the search index and Instantsearch frontend to separate tag levels
9
+ # e.g. tags_level3="Earth > North America > Mexico > Mexico City"
10
+ ';', # Used in CSV exports to separate multiple tags from the same taxonomy
11
+ # e.g. languages-v1: en;es;fr
12
+ ]
13
+ TAGS_CSV_SEPARATOR = RESERVED_TAG_CHARS[2]
File without changes
@@ -0,0 +1,15 @@
1
+ Tagging App
2
+ ===========
3
+
4
+ The ``openedx_tagging`` app will enable content authors to tag pieces of content and quickly
5
+ filter for ease of re-use.
6
+
7
+ Motivation
8
+ ----------
9
+
10
+ Tagging content is central to enable content re-use, facilitate the implementation
11
+ of flexible content structures different from the current implementation and
12
+ allow adaptive learning in the Open edX platform.
13
+
14
+ This service has been implemented as pluggable django app. Since it is necessary for
15
+ it to work independently of the content to which it links to.
File without changes
@@ -0,0 +1,105 @@
1
+ """
2
+ Paginators uses by the REST API
3
+ """
4
+
5
+ from typing import Type
6
+
7
+ from edx_rest_framework_extensions.paginators import DefaultPagination # type: ignore[import]
8
+ from rest_framework.request import Request
9
+ from rest_framework.response import Response
10
+
11
+ from openedx_tagging.models import Tag, Taxonomy
12
+
13
+ from .utils import UserPermissionsHelper
14
+
15
+ # From this point, the tags begin to be paginated
16
+ MAX_FULL_DEPTH_THRESHOLD = 10_000
17
+
18
+
19
+ class CanAddPermissionMixin(UserPermissionsHelper): # pylint: disable=abstract-method
20
+ """
21
+ This mixin inserts a boolean "can_add_<model>" field at the top level of the paginated response.
22
+
23
+ The value of the field indicates whether request user may create new instances of the current model.
24
+ """
25
+
26
+ @property
27
+ def _request(self) -> Request:
28
+ """Returns the current request for UserPermissionsHelper"""
29
+ return self.request # type: ignore[attr-defined]
30
+
31
+ def get_paginated_response(self, data) -> Response:
32
+ """
33
+ Injects the user's model-level permissions into the paginated response.
34
+ """
35
+ response_data = super().get_paginated_response(data).data # type: ignore[misc]
36
+ field_name = f"can_add_{self.model_name}"
37
+ response_data[field_name] = self.get_can_add()
38
+ return Response(response_data)
39
+
40
+
41
+ class TaxonomyPagination(CanAddPermissionMixin, DefaultPagination):
42
+ """
43
+ Inserts permissions data for Taxonomies into the top level of the paginated response.
44
+ """
45
+
46
+ page_size = 500
47
+ max_page_size = 500
48
+
49
+ @property
50
+ def _model(self) -> Type:
51
+ """
52
+ Returns the model that is being paginated.
53
+ """
54
+ return Taxonomy
55
+
56
+
57
+ class TagPermissionsMixin(CanAddPermissionMixin):
58
+ """
59
+ Checks "add_tag" permission using a Tag bound to the current taxonomy, so that taxonomies with
60
+ read_only=True correctly report can_add_tag=False.
61
+ """
62
+
63
+ @property
64
+ def _model(self) -> Type:
65
+ """
66
+ Returns the model that is being paginated.
67
+ """
68
+ return Tag
69
+
70
+ def paginate_queryset(self, queryset, request, view=None):
71
+ self._taxonomy = view.get_taxonomy() if view else None
72
+ return super().paginate_queryset(queryset, request, view=view)
73
+
74
+ def get_can_add(self, instance: Tag | None = None) -> bool | None:
75
+ if instance is None and self._taxonomy:
76
+ instance = Tag(taxonomy=self._taxonomy)
77
+ # Check read-only here (as well as via get_can_add()), because the permissions check is skipped for superusers
78
+ # but even they shouldn't be able to modify tags in read-only taxonomies.
79
+ if instance and instance.taxonomy and instance.taxonomy.read_only:
80
+ return False
81
+ return super().get_can_add(instance)
82
+
83
+
84
+ class TagsPagination(TagPermissionsMixin, DefaultPagination):
85
+ """
86
+ Custom pagination configuration for taxonomies
87
+ with a large number of tags. Used on the get tags API view.
88
+ """
89
+
90
+ page_size = 10
91
+ max_page_size = 300
92
+
93
+
94
+ class DisabledTagsPagination(TagPermissionsMixin, DefaultPagination):
95
+ """
96
+ Custom pagination configuration for taxonomies
97
+ with a small number of tags. Used on the get tags API view
98
+
99
+ This class allows to bring all the tags of the taxonomy.
100
+ It should be used if the number of tags within
101
+ the taxonomy does not exceed `TAGS_THRESHOLD`.
102
+ """
103
+
104
+ page_size = MAX_FULL_DEPTH_THRESHOLD
105
+ max_page_size = MAX_FULL_DEPTH_THRESHOLD + 1
@@ -0,0 +1,9 @@
1
+ """
2
+ Taxonomies API URLs.
3
+ """
4
+
5
+ from django.urls import include, path
6
+
7
+ from .v1 import urls as v1_urls
8
+
9
+ urlpatterns = [path("v1/", include(v1_urls))]
@@ -0,0 +1,110 @@
1
+ """
2
+ Utilities for the API
3
+ """
4
+ from typing import Optional, Type
5
+
6
+ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # type: ignore[import]
7
+ from edx_rest_framework_extensions.auth.session.authentication import ( # type: ignore[import]
8
+ SessionAuthenticationAllowInactiveUser,
9
+ )
10
+ from rest_framework.request import Request
11
+
12
+
13
+ def view_auth_classes(func_or_class):
14
+ """
15
+ Function and class decorator that abstracts the authentication classes for api views.
16
+ """
17
+ def _decorator(func_or_class):
18
+ """
19
+ Requires either OAuth2 or Session-based authentication;
20
+ are the same authentication classes used on edx-platform
21
+ """
22
+ func_or_class.authentication_classes = (
23
+ JwtAuthentication,
24
+ SessionAuthenticationAllowInactiveUser,
25
+ )
26
+ return func_or_class
27
+ return _decorator(func_or_class)
28
+
29
+
30
+ class UserPermissionsHelper:
31
+ """
32
+ Provides helper methods for serializing user permissions.
33
+ """
34
+ @property
35
+ def _request(self) -> Request:
36
+ """
37
+ Returns the current request.
38
+ """
39
+ raise NotImplementedError # pragma: no cover
40
+
41
+ @property
42
+ def _model(self) -> Type:
43
+ """
44
+ Returns the model used when checking permissions.
45
+ """
46
+ raise NotImplementedError # pragma: no cover
47
+
48
+ @property
49
+ def app_label(self) -> Type:
50
+ """
51
+ Returns the app_label for the model used when checking permissions.
52
+ """
53
+ return self._model._meta.app_label
54
+
55
+ @property
56
+ def model_name(self) -> Type:
57
+ """
58
+ Returns the name of the model used when checking permissions.
59
+ """
60
+ return self._model._meta.model_name
61
+
62
+ def _get_permission_name(self, action: str) -> str:
63
+ """
64
+ Returns the fully-qualified permission name corresponding to the current model and `action`.
65
+ """
66
+ assert action in ("add", "view", "change", "delete")
67
+ return f'{self.app_label}.{action}_{self.model_name}'
68
+
69
+ def _can(self, perm_name: str, instance=None) -> Optional[bool]:
70
+ """
71
+ Does the current `request.user` have the given `perm` on the `instance` object?
72
+
73
+ Returns None if no permissions were requested.
74
+ Returns True if they may.
75
+ Returns False if they may not.
76
+ """
77
+ request = self._request
78
+ assert request and request.user
79
+ return request.user.has_perm(perm_name, instance)
80
+
81
+ def get_can_add(self, instance=None) -> Optional[bool]:
82
+ """
83
+ Returns True if the current user is allowed to add new instances.
84
+
85
+ `instance` is optional: most tagging models don't need one to check "add" permissions, but some
86
+ (e.g. Tag, whose "add" permission depends on its taxonomy's read_only flag) do.
87
+ """
88
+ perm_name = self._get_permission_name('add')
89
+ return self._can(perm_name, instance)
90
+
91
+ def get_can_view(self, instance) -> Optional[bool]:
92
+ """
93
+ Returns True if the current user is allowed to view/see this instance.
94
+ """
95
+ perm_name = self._get_permission_name('view')
96
+ return self._can(perm_name, instance)
97
+
98
+ def get_can_change(self, instance) -> Optional[bool]:
99
+ """
100
+ Returns True if the current user is allowed to edit/change this instance.
101
+ """
102
+ perm_name = self._get_permission_name('change')
103
+ return self._can(perm_name, instance)
104
+
105
+ def get_can_delete(self, instance) -> Optional[bool]:
106
+ """
107
+ Returns True if the current user is allowed to delete this instance.
108
+ """
109
+ perm_name = self._get_permission_name('change')
110
+ return self._can(perm_name, instance)
File without changes
@@ -0,0 +1,58 @@
1
+ """
2
+ Tagging REST API exception handling utilities.
3
+ """
4
+
5
+ import logging
6
+ import traceback
7
+
8
+ from django.conf import settings
9
+ from django.http import Http404
10
+ from rest_framework import status
11
+ from rest_framework.exceptions import APIException, PermissionDenied
12
+ from rest_framework.response import Response
13
+ from rest_framework.views import exception_handler
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def _custom_exception_handler(exc: Exception, context: dict) -> Response | None:
19
+ """
20
+ Return standard DRF errors for APIException and a generic 500 otherwise.
21
+ This exception handler should eventually be replaced by a more top-level
22
+ exception handler in the openedx-platform repo after the ADR for it is accepted:
23
+ https://github.com/openedx/openedx-platform/pull/38246
24
+ """
25
+ # For exceptions expected by DRF return the standard DRF error response:
26
+ # Instances of APIException, subclasses of APIException, Django's Http404 exception,
27
+ # and Django's PermissionDenied exception.
28
+ is_expected_exception = isinstance(
29
+ exc, (APIException, Http404, PermissionDenied)
30
+ )
31
+ if is_expected_exception:
32
+ return exception_handler(exc, context)
33
+
34
+ # DRF always calls exception handlers from within an `except:` block, so we can assume
35
+ # that `log.exception` will automatically insert those exception details and a stack trace.
36
+ log.exception("Unexpected exception while handling API request")
37
+
38
+ if settings.DEBUG:
39
+ description_with_traceback = f"{exc.__class__.__name__}: {str(exc)}\n\nTraceback:\n{traceback.format_exc()}"
40
+
41
+ return Response(
42
+ {"detail": description_with_traceback},
43
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
44
+ )
45
+
46
+ return Response(
47
+ {"detail": "An unexpected error occurred while processing the request."},
48
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
49
+ )
50
+
51
+
52
+ class TaggingExceptionHandlerMixin:
53
+ """
54
+ Scope custom exception handling to tagging API views only.
55
+ """
56
+
57
+ def get_exception_handler(self):
58
+ return _custom_exception_handler
@@ -0,0 +1,68 @@
1
+ """
2
+ Tagging permissions
3
+ """
4
+ import rules
5
+ from rest_framework.permissions import DjangoObjectPermissions
6
+
7
+ from ...models import Tag, Taxonomy
8
+
9
+
10
+ class TaxonomyObjectPermissions(DjangoObjectPermissions):
11
+ """
12
+ Maps each REST API methods to its corresponding Taxonomy permission.
13
+ """
14
+ perms_map = {
15
+ "GET": ["%(app_label)s.view_%(model_name)s"],
16
+ "OPTIONS": [],
17
+ "HEAD": ["%(app_label)s.view_%(model_name)s"],
18
+ "POST": ["%(app_label)s.add_%(model_name)s"],
19
+ "PUT": ["%(app_label)s.change_%(model_name)s"],
20
+ "PATCH": ["%(app_label)s.change_%(model_name)s"],
21
+ "DELETE": ["%(app_label)s.delete_%(model_name)s"],
22
+ }
23
+
24
+
25
+ class ObjectTagObjectPermissions(DjangoObjectPermissions):
26
+ """
27
+ Maps each REST API methods to its corresponding ObjectTag permission.
28
+ """
29
+ perms_map = {
30
+ "GET": ["%(app_label)s.view_%(model_name)s"],
31
+ "OPTIONS": [],
32
+ "HEAD": ["%(app_label)s.view_%(model_name)s"],
33
+ "POST": ["%(app_label)s.add_%(model_name)s"],
34
+ "PUT": ["%(app_label)s.change_%(model_name)s"],
35
+ "PATCH": ["%(app_label)s.change_%(model_name)s"],
36
+ "DELETE": ["%(app_label)s.delete_%(model_name)s"],
37
+ }
38
+
39
+
40
+ class TaxonomyTagsObjectPermissions(DjangoObjectPermissions):
41
+ """
42
+ Maps each REST API methods to its corresponding Taxonomy permission.
43
+ """
44
+ perms_map = {
45
+ "GET": ["%(app_label)s.view_%(model_name)s"],
46
+ "OPTIONS": [],
47
+ "HEAD": ["%(app_label)s.view_%(model_name)s"],
48
+ "POST": ["%(app_label)s.add_%(model_name)s"],
49
+ "PUT": ["%(app_label)s.change_%(model_name)s"],
50
+ "PATCH": ["%(app_label)s.change_%(model_name)s"],
51
+ "DELETE": ["%(app_label)s.delete_%(model_name)s"],
52
+ }
53
+
54
+ # This is to handle the special case for GET list of Taxonomy Tags
55
+ def has_object_permission(self, request, view, obj):
56
+ """
57
+ Returns True if the user on the given request is allowed the given view for the given object.
58
+ """
59
+ obj = obj.taxonomy if isinstance(obj, Tag) else obj
60
+ return rules.has_perm("oel_tagging.list_tag", request.user, obj)
61
+
62
+ def _queryset(self, view):
63
+ """
64
+ Returns the queryset to use when checking model and object permissions.
65
+
66
+ The base class method calls view.get_queryset(), but that method performs database queries, so we override it.
67
+ """
68
+ return Taxonomy.objects