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.
- openedx_catalog/ARCHITECTURE.md +36 -0
- openedx_catalog/README.rst +22 -0
- openedx_catalog/__init__.py +0 -0
- openedx_catalog/admin.py +112 -0
- openedx_catalog/api.py +30 -0
- openedx_catalog/api_impl.py +251 -0
- openedx_catalog/apps.py +21 -0
- openedx_catalog/migrations/0001_initial.py +209 -0
- openedx_catalog/migrations/__init__.py +0 -0
- openedx_catalog/models/__init__.py +6 -0
- openedx_catalog/models/catalog_course.py +246 -0
- openedx_catalog/models/course_run.py +256 -0
- openedx_catalog/models_api.py +10 -0
- openedx_catalog/py.typed +2 -0
- openedx_catalog/signals.py +51 -0
- openedx_content/README.rst +5 -0
- openedx_content/__init__.py +0 -0
- openedx_content/admin.py +11 -0
- openedx_content/api.py +28 -0
- openedx_content/applets/__init__.py +0 -0
- openedx_content/applets/backup_restore/__init__.py +0 -0
- openedx_content/applets/backup_restore/admin.py +3 -0
- openedx_content/applets/backup_restore/api.py +42 -0
- openedx_content/applets/backup_restore/models.py +3 -0
- openedx_content/applets/backup_restore/serializers.py +216 -0
- openedx_content/applets/backup_restore/toml.py +259 -0
- openedx_content/applets/backup_restore/zipper.py +1139 -0
- openedx_content/applets/collections/__init__.py +0 -0
- openedx_content/applets/collections/admin.py +42 -0
- openedx_content/applets/collections/api.py +336 -0
- openedx_content/applets/collections/models.py +253 -0
- openedx_content/applets/collections/readme.rst +23 -0
- openedx_content/applets/collections/signals.py +80 -0
- openedx_content/applets/collections/tasks.py +86 -0
- openedx_content/applets/components/__init__.py +0 -0
- openedx_content/applets/components/admin.py +159 -0
- openedx_content/applets/components/api.py +696 -0
- openedx_content/applets/components/models.py +324 -0
- openedx_content/applets/components/readme.rst +23 -0
- openedx_content/applets/containers/__init__.py +0 -0
- openedx_content/applets/containers/admin.py +343 -0
- openedx_content/applets/containers/api.py +920 -0
- openedx_content/applets/containers/models.py +350 -0
- openedx_content/applets/media/__init__.py +0 -0
- openedx_content/applets/media/admin.py +71 -0
- openedx_content/applets/media/api.py +241 -0
- openedx_content/applets/media/models.py +486 -0
- openedx_content/applets/publishing/__init__.py +0 -0
- openedx_content/applets/publishing/admin.py +344 -0
- openedx_content/applets/publishing/api.py +1793 -0
- openedx_content/applets/publishing/contextmanagers.py +157 -0
- openedx_content/applets/publishing/models/__init__.py +26 -0
- openedx_content/applets/publishing/models/draft_log.py +438 -0
- openedx_content/applets/publishing/models/learning_package.py +91 -0
- openedx_content/applets/publishing/models/publish_log.py +316 -0
- openedx_content/applets/publishing/models/publishable_entity.py +740 -0
- openedx_content/applets/publishing/readme.rst +28 -0
- openedx_content/applets/publishing/signal_handlers.py +41 -0
- openedx_content/applets/publishing/signals.py +247 -0
- openedx_content/applets/sections/__init__.py +0 -0
- openedx_content/applets/sections/api.py +134 -0
- openedx_content/applets/sections/models.py +78 -0
- openedx_content/applets/subsections/__init__.py +0 -0
- openedx_content/applets/subsections/api.py +134 -0
- openedx_content/applets/subsections/models.py +79 -0
- openedx_content/applets/units/__init__.py +0 -0
- openedx_content/applets/units/api.py +134 -0
- openedx_content/applets/units/models.py +74 -0
- openedx_content/apps.py +52 -0
- openedx_content/backcompat/__init__.py +0 -0
- openedx_content/backcompat/backup_restore/__init__.py +0 -0
- openedx_content/backcompat/backup_restore/apps.py +12 -0
- openedx_content/backcompat/backup_restore/migrations/__init__.py +0 -0
- openedx_content/backcompat/collections/__init__.py +0 -0
- openedx_content/backcompat/collections/apps.py +14 -0
- openedx_content/backcompat/collections/migrations/0001_initial.py +33 -0
- openedx_content/backcompat/collections/migrations/0002_remove_collection_name_collection_created_by_and_more.py +53 -0
- openedx_content/backcompat/collections/migrations/0003_collection_entities.py +38 -0
- openedx_content/backcompat/collections/migrations/0004_collection_key.py +57 -0
- openedx_content/backcompat/collections/migrations/0005_alter_collection_options_alter_collection_enabled.py +18 -0
- openedx_content/backcompat/collections/migrations/0006_remove_all_field_state_for_move_to_applet.py +82 -0
- openedx_content/backcompat/collections/migrations/__init__.py +0 -0
- openedx_content/backcompat/collections/models.py +9 -0
- openedx_content/backcompat/components/__init__.py +0 -0
- openedx_content/backcompat/components/apps.py +14 -0
- openedx_content/backcompat/components/migrations/0001_initial.py +97 -0
- openedx_content/backcompat/components/migrations/0002_alter_componentversioncontent_key.py +20 -0
- openedx_content/backcompat/components/migrations/0003_remove_componentversioncontent_learner_downloadable.py +17 -0
- openedx_content/backcompat/components/migrations/0004_remove_componentversioncontent_uuid.py +17 -0
- openedx_content/backcompat/components/migrations/0005_remove_all_field_state_for_move_to_applet.py +72 -0
- openedx_content/backcompat/components/migrations/__init__.py +0 -0
- openedx_content/backcompat/components/models.py +11 -0
- openedx_content/backcompat/contents/__init__.py +0 -0
- openedx_content/backcompat/contents/apps.py +14 -0
- openedx_content/backcompat/contents/migrations/0001_initial.py +66 -0
- openedx_content/backcompat/contents/migrations/0002_remove_all_field_state_for_move_to_applet.py +26 -0
- openedx_content/backcompat/contents/migrations/__init__.py +0 -0
- openedx_content/backcompat/publishing/__init__.py +0 -0
- openedx_content/backcompat/publishing/apps.py +14 -0
- openedx_content/backcompat/publishing/migrations/0001_initial.py +166 -0
- openedx_content/backcompat/publishing/migrations/0002_alter_learningpackage_key_and_more.py +25 -0
- openedx_content/backcompat/publishing/migrations/0003_containers.py +54 -0
- openedx_content/backcompat/publishing/migrations/0004_publishableentity_can_stand_alone.py +21 -0
- openedx_content/backcompat/publishing/migrations/0005_alter_entitylistrow_options.py +17 -0
- openedx_content/backcompat/publishing/migrations/0006_draftchangelog.py +68 -0
- openedx_content/backcompat/publishing/migrations/0007_bootstrap_draftchangelog.py +94 -0
- openedx_content/backcompat/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py +31 -0
- openedx_content/backcompat/publishing/migrations/0009_dependencies_and_hashing.py +62 -0
- openedx_content/backcompat/publishing/migrations/0010_backfill_dependencies.py +441 -0
- openedx_content/backcompat/publishing/migrations/0011_remove_all_field_state_for_move_to_applet.py +288 -0
- openedx_content/backcompat/publishing/migrations/__init__.py +0 -0
- openedx_content/backcompat/publishing/models.py +27 -0
- openedx_content/backcompat/readme.rst +6 -0
- openedx_content/backcompat/sections/__init__.py +0 -0
- openedx_content/backcompat/sections/apps.py +15 -0
- openedx_content/backcompat/sections/migrations/0001_initial.py +36 -0
- openedx_content/backcompat/sections/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
- openedx_content/backcompat/sections/migrations/__init__.py +0 -0
- openedx_content/backcompat/subsections/__init__.py +0 -0
- openedx_content/backcompat/subsections/apps.py +15 -0
- openedx_content/backcompat/subsections/migrations/0001_initial.py +36 -0
- openedx_content/backcompat/subsections/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
- openedx_content/backcompat/subsections/migrations/__init__.py +0 -0
- openedx_content/backcompat/units/__init__.py +0 -0
- openedx_content/backcompat/units/apps.py +15 -0
- openedx_content/backcompat/units/migrations/0001_initial.py +36 -0
- openedx_content/backcompat/units/migrations/0002_remove_all_field_state_for_move_to_applet.py +29 -0
- openedx_content/backcompat/units/migrations/__init__.py +0 -0
- openedx_content/management/__init__.py +0 -0
- openedx_content/management/commands/__init__.py +0 -0
- openedx_content/management/commands/add_assets_to_component.py +86 -0
- openedx_content/management/commands/lp_dump.py +69 -0
- openedx_content/management/commands/lp_load.py +57 -0
- openedx_content/migrations/0001_initial.py +654 -0
- openedx_content/migrations/0002_rename_tables_to_openedx_content.py +138 -0
- openedx_content/migrations/0003_rename_content_to_media.py +51 -0
- openedx_content/migrations/0004_componenttype_constraint.py +49 -0
- openedx_content/migrations/0005_containertypes.py +94 -0
- openedx_content/migrations/0006_typed_ids.py +29 -0
- openedx_content/migrations/0007_publishlogrecord_direct.py +32 -0
- openedx_content/migrations/0008_rename_collection_key_to_collection_code.py +72 -0
- openedx_content/migrations/0009_rename_component_local_key_to_component_code.py +81 -0
- openedx_content/migrations/0010_add_container_code.py +104 -0
- openedx_content/migrations/0011_rename_entity_key_and_package_key_to_refs.py +84 -0
- openedx_content/migrations/0012_rename_componentversionmedia_key_to_path.py +46 -0
- openedx_content/migrations/0013_unicode_container_component_codes.py +60 -0
- openedx_content/migrations/0014_typed_media_id.py +28 -0
- openedx_content/migrations/__init__.py +0 -0
- openedx_content/models.py +18 -0
- openedx_content/models_api.py +18 -0
- openedx_content/py.typed +2 -0
- openedx_content/settings_api.py +38 -0
- openedx_content/signals.py +20 -0
- openedx_core/__init__.py +10 -0
- openedx_core-1.4.1.dist-info/METADATA +137 -0
- openedx_core-1.4.1.dist-info/RECORD +244 -0
- openedx_core-1.4.1.dist-info/WHEEL +5 -0
- openedx_core-1.4.1.dist-info/licenses/LICENSE.txt +674 -0
- openedx_core-1.4.1.dist-info/top_level.txt +6 -0
- openedx_django_lib/README.rst +9 -0
- openedx_django_lib/__init__.py +6 -0
- openedx_django_lib/admin_utils.py +116 -0
- openedx_django_lib/collations.py +65 -0
- openedx_django_lib/fields.py +282 -0
- openedx_django_lib/id_fields.py +57 -0
- openedx_django_lib/id_fields.pyi +17 -0
- openedx_django_lib/managers.py +48 -0
- openedx_django_lib/py.typed +2 -0
- openedx_django_lib/validators.py +15 -0
- openedx_learning/README.rst +11 -0
- openedx_learning/__init__.py +3 -0
- openedx_learning/admin.py +6 -0
- openedx_learning/api.py +6 -0
- openedx_learning/applets/__init__.py +3 -0
- openedx_learning/applets/cbe/README.rst +21 -0
- openedx_learning/applets/cbe/__init__.py +3 -0
- openedx_learning/applets/cbe/admin.py +21 -0
- openedx_learning/applets/cbe/api.py +65 -0
- openedx_learning/applets/cbe/models.py +40 -0
- openedx_learning/apps.py +15 -0
- openedx_learning/migrations/0001_initial.py +27 -0
- openedx_learning/migrations/__init__.py +0 -0
- openedx_learning/models.py +7 -0
- openedx_learning/models_api.py +9 -0
- openedx_learning/py.typed +0 -0
- openedx_tagging/__init__.py +0 -0
- openedx_tagging/admin.py +44 -0
- openedx_tagging/api.py +573 -0
- openedx_tagging/apps.py +23 -0
- openedx_tagging/data.py +39 -0
- openedx_tagging/import_export/__init__.py +4 -0
- openedx_tagging/import_export/actions.py +451 -0
- openedx_tagging/import_export/api.py +231 -0
- openedx_tagging/import_export/exceptions.py +133 -0
- openedx_tagging/import_export/import_plan.py +218 -0
- openedx_tagging/import_export/parsers.py +351 -0
- openedx_tagging/import_export/tasks.py +43 -0
- openedx_tagging/import_export/template.csv +30 -0
- openedx_tagging/import_export/template.json +158 -0
- openedx_tagging/migrations/0001_initial.py +212 -0
- openedx_tagging/migrations/0001_squashed.py +152 -0
- openedx_tagging/migrations/0002_auto_20230718_2026.py +79 -0
- openedx_tagging/migrations/0003_auto_20230721_1238.py +76 -0
- openedx_tagging/migrations/0004_auto_20230723_2001.py +35 -0
- openedx_tagging/migrations/0005_language_taxonomy.py +31 -0
- openedx_tagging/migrations/0006_alter_objecttag_unique_together.py +16 -0
- openedx_tagging/migrations/0006_auto_20230802_1631.py +82 -0
- openedx_tagging/migrations/0007_tag_import_task_log_null_fix.py +19 -0
- openedx_tagging/migrations/0008_taxonomy_description_not_null.py +24 -0
- openedx_tagging/migrations/0009_alter_objecttag_object_id.py +20 -0
- openedx_tagging/migrations/0010_cleanups.py +32 -0
- openedx_tagging/migrations/0011_remove_required.py +22 -0
- openedx_tagging/migrations/0012_language_taxonomy.py +43 -0
- openedx_tagging/migrations/0013_tag_parent_blank.py +19 -0
- openedx_tagging/migrations/0014_minor_fixes.py +36 -0
- openedx_tagging/migrations/0015_taxonomy_export_id.py +38 -0
- openedx_tagging/migrations/0016_object_tag_export_id.py +62 -0
- openedx_tagging/migrations/0017_alter_tagimporttask_status.py +18 -0
- openedx_tagging/migrations/0018_objecttag_is_copied.py +18 -0
- openedx_tagging/migrations/0019_language_taxonomy_class.py +33 -0
- openedx_tagging/migrations/0020_tag_depth_and_lineage.py +163 -0
- openedx_tagging/migrations/0021_remove_system_defined_add_read_only.py +89 -0
- openedx_tagging/migrations/__init__.py +0 -0
- openedx_tagging/models/__init__.py +5 -0
- openedx_tagging/models/base.py +885 -0
- openedx_tagging/models/import_export.py +150 -0
- openedx_tagging/models/utils.py +13 -0
- openedx_tagging/py.typed +0 -0
- openedx_tagging/readme.rst +15 -0
- openedx_tagging/rest_api/__init__.py +0 -0
- openedx_tagging/rest_api/paginators.py +105 -0
- openedx_tagging/rest_api/urls.py +9 -0
- openedx_tagging/rest_api/utils.py +110 -0
- openedx_tagging/rest_api/v1/__init__.py +0 -0
- openedx_tagging/rest_api/v1/exception_handlers.py +58 -0
- openedx_tagging/rest_api/v1/permissions.py +68 -0
- openedx_tagging/rest_api/v1/serializers.py +476 -0
- openedx_tagging/rest_api/v1/urls.py +27 -0
- openedx_tagging/rest_api/v1/views.py +969 -0
- openedx_tagging/rest_api/v1/views_import.py +53 -0
- openedx_tagging/rules.py +210 -0
- openedx_tagging/signal_handlers.py +119 -0
- openedx_tagging/tasks.py +82 -0
- 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]
|
openedx_tagging/py.typed
ADDED
|
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,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
|