openedx-learning 0.6.2__py2.py3-none-any.whl → 0.8.0__py2.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_learning/__init__.py +1 -1
- openedx_learning/py.typed +0 -0
- {openedx_learning-0.6.2.dist-info → openedx_learning-0.8.0.dist-info}/METADATA +8 -9
- {openedx_learning-0.6.2.dist-info → openedx_learning-0.8.0.dist-info}/RECORD +15 -12
- {openedx_learning-0.6.2.dist-info → openedx_learning-0.8.0.dist-info}/WHEEL +1 -1
- openedx_tagging/core/tagging/admin.py +1 -1
- openedx_tagging/core/tagging/api.py +115 -37
- openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py +62 -0
- openedx_tagging/core/tagging/models/base.py +40 -23
- openedx_tagging/core/tagging/models/utils.py +10 -0
- openedx_tagging/core/tagging/rest_api/v1/serializers.py +3 -2
- openedx_tagging/core/tagging/rest_api/v1/views.py +6 -1
- openedx_tagging/py.typed +0 -0
- {openedx_learning-0.6.2.dist-info → openedx_learning-0.8.0.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.6.2.dist-info → openedx_learning-0.8.0.dist-info}/top_level.txt +0 -0
openedx_learning/__init__.py
CHANGED
|
File without changes
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: openedx-learning
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: An experiment.
|
|
5
5
|
Home-page: https://github.com/openedx/openedx-learning
|
|
6
6
|
Author: David Ormsbee
|
|
7
7
|
Author-email: dave@tcril.org
|
|
8
8
|
License: AGPL 3.0
|
|
9
9
|
Keywords: Python edx
|
|
10
|
-
Platform: UNKNOWN
|
|
11
10
|
Classifier: Development Status :: 3 - Alpha
|
|
12
11
|
Classifier: Framework :: Django
|
|
13
12
|
Classifier: Framework :: Django :: 3.2
|
|
@@ -16,13 +15,15 @@ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or l
|
|
|
16
15
|
Classifier: Natural Language :: English
|
|
17
16
|
Classifier: Programming Language :: Python :: 3
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Requires-Python: >=3.8
|
|
20
|
-
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: Django (<5.0)
|
|
24
|
-
Requires-Dist: djangorestframework (<4.0)
|
|
20
|
+
License-File: LICENSE.txt
|
|
21
|
+
Requires-Dist: rules <4.0
|
|
22
|
+
Requires-Dist: Django <5.0
|
|
25
23
|
Requires-Dist: edx-drf-extensions
|
|
24
|
+
Requires-Dist: attrs
|
|
25
|
+
Requires-Dist: djangorestframework <4.0
|
|
26
|
+
Requires-Dist: celery
|
|
26
27
|
|
|
27
28
|
openedx-learning
|
|
28
29
|
=============================
|
|
@@ -222,5 +223,3 @@ Added
|
|
|
222
223
|
_____
|
|
223
224
|
|
|
224
225
|
* First release on PyPI.
|
|
225
|
-
|
|
226
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
openedx_learning/__init__.py,sha256=
|
|
1
|
+
openedx_learning/__init__.py,sha256=_N21txzaFcs_eHSkmGfXQjBRuBlyFhJELEUysAn134Q,67
|
|
2
|
+
openedx_learning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
3
|
openedx_learning/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
4
|
openedx_learning/contrib/media_server/__init__.py,sha256=iYijWFCl5RNR9omSu22kMl49EfponoqXBqXr0HMp4QI,56
|
|
4
5
|
openedx_learning/contrib/media_server/apps.py,sha256=FPT0rsUFtPyhFpWKjSI1e_s58wU0IbDyaAW_66V6sY4,816
|
|
@@ -44,10 +45,11 @@ openedx_learning/rest_api/v1/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
|
|
|
44
45
|
openedx_learning/rest_api/v1/components.py,sha256=OYNWqarNtVmiKunIMlZWNGVCeZ5ATco2ZTuxPR-BS9I,757
|
|
45
46
|
openedx_learning/rest_api/v1/urls.py,sha256=g0nYjq6qWjd08TUzsb-tQ_ZFRmQsxCIQvQRNa6oCAns,256
|
|
46
47
|
openedx_tagging/__init__.py,sha256=V9N8M7f9LYlAbA_DdPUsHzTnWjYRXKGa5qHw9P1JnNI,30
|
|
48
|
+
openedx_tagging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
47
49
|
openedx_tagging/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
48
50
|
openedx_tagging/core/tagging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
49
|
-
openedx_tagging/core/tagging/admin.py,sha256=
|
|
50
|
-
openedx_tagging/core/tagging/api.py,sha256=
|
|
51
|
+
openedx_tagging/core/tagging/admin.py,sha256=Ngc2l9Mf6gkzmqu7aOwq-d0mgV8szx0GzSeuWFX7Kyg,1080
|
|
52
|
+
openedx_tagging/core/tagging/api.py,sha256=JlS8KQiTBd0rEQqEqE5hf7QnCnR9KZRFw-ShcJPhJ4U,18749
|
|
51
53
|
openedx_tagging/core/tagging/apps.py,sha256=-gp0VYqX4XQzwjjd-G68Ev2Op0INLh9Byz5UOqF5_7k,345
|
|
52
54
|
openedx_tagging/core/tagging/data.py,sha256=421EvmDzdM7H523dBVQk4J0W_UwTT4U5syqPRXUYK4g,1353
|
|
53
55
|
openedx_tagging/core/tagging/rules.py,sha256=UqIPPbOVA6FFF6uqLk0s5ORUczSYQt-a-S6Q_dB-RiE,6286
|
|
@@ -78,24 +80,25 @@ openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py,sha256=asxUvOl
|
|
|
78
80
|
openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py,sha256=N0Bk_p_l38t6syyJxNKHB95D5MkZqUgr90ISRbBOCKA,619
|
|
79
81
|
openedx_tagging/core/tagging/migrations/0014_minor_fixes.py,sha256=46_F-el1UylSRnIRbY2DHeZpA9GJLrmGpEuF-urAtt4,1406
|
|
80
82
|
openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py,sha256=jhS-T8o2mwu61E7hPbjjE_6MPLKRPQFAVu7pJHZNRz4,1454
|
|
83
|
+
openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py,sha256=X62sThGNv_0_wCBhWPcYRcs90EJwz9km9Nszr9yMVkM,2371
|
|
81
84
|
openedx_tagging/core/tagging/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
82
85
|
openedx_tagging/core/tagging/models/__init__.py,sha256=yYdOnthuc7EUdfEULtZgqRwn5Y4bbYQmJCjVZqR5GTM,236
|
|
83
|
-
openedx_tagging/core/tagging/models/base.py,sha256=
|
|
86
|
+
openedx_tagging/core/tagging/models/base.py,sha256=1WAPxssL8thAg8LHh1GbwSo98H81-nVdTVQt1nC1ZdU,39335
|
|
84
87
|
openedx_tagging/core/tagging/models/import_export.py,sha256=cXsTk44CO6RZvmiMI5ERsyjuM-wYnJoORwCsGEj6OEc,4125
|
|
85
88
|
openedx_tagging/core/tagging/models/system_defined.py,sha256=_6LfvUZGEltvQMtm2OXy6TOLh3C8GnVTqtZDSAZW6K4,9062
|
|
86
|
-
openedx_tagging/core/tagging/models/utils.py,sha256
|
|
89
|
+
openedx_tagging/core/tagging/models/utils.py,sha256=-A3Dj24twmTf65UB7G4WLvb_9qEvduEPIwahZ-FJDlg,1926
|
|
87
90
|
openedx_tagging/core/tagging/rest_api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
88
91
|
openedx_tagging/core/tagging/rest_api/paginators.py,sha256=BUIAg3taihHx7uAjpTZAGK1xSZzZY9G0aib4OKv5c0k,2651
|
|
89
92
|
openedx_tagging/core/tagging/rest_api/urls.py,sha256=egXaRQv1EAgF04ThgVZBQuvLK1LimuyUKKBD2Hbqb10,148
|
|
90
93
|
openedx_tagging/core/tagging/rest_api/utils.py,sha256=XZXixZ44vpNlxiyFplW8Lktyh_m1EfR3Y-tnyvA7acc,3620
|
|
91
94
|
openedx_tagging/core/tagging/rest_api/v1/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
92
95
|
openedx_tagging/core/tagging/rest_api/v1/permissions.py,sha256=7HPE_NuKku_ISnkeE_HsFNXVYt0IbVkJN6M4wqwHGHU,2443
|
|
93
|
-
openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=
|
|
96
|
+
openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=XZ0iNRvVTKvO5rUGTOqPd2g1z0DPJjCtd4QOnRaQYCs,13386
|
|
94
97
|
openedx_tagging/core/tagging/rest_api/v1/urls.py,sha256=dNUKCtUCx_YzrwlbEbpDfjGVQbb2QdJ1VuJCkladj6E,752
|
|
95
|
-
openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=
|
|
98
|
+
openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=raxgG0iiKSfU4M3_9GIFiT1zWbyuR1Y_WFjcRzryK_U,34778
|
|
96
99
|
openedx_tagging/core/tagging/rest_api/v1/views_import.py,sha256=kbHUPe5A6WaaJ3J1lFIcYCt876ecLNQfd19m7YYub6c,1470
|
|
97
|
-
openedx_learning-0.
|
|
98
|
-
openedx_learning-0.
|
|
99
|
-
openedx_learning-0.
|
|
100
|
-
openedx_learning-0.
|
|
101
|
-
openedx_learning-0.
|
|
100
|
+
openedx_learning-0.8.0.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
|
|
101
|
+
openedx_learning-0.8.0.dist-info/METADATA,sha256=fra-pVZqXDivTwt_43cafF90HTDt5i0Shge6AtP1TV8,8809
|
|
102
|
+
openedx_learning-0.8.0.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
|
|
103
|
+
openedx_learning-0.8.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
|
|
104
|
+
openedx_learning-0.8.0.dist-info/RECORD,,
|
|
@@ -34,7 +34,7 @@ class ObjectTagAdmin(admin.ModelAdmin):
|
|
|
34
34
|
"""
|
|
35
35
|
fields = ["object_id", "taxonomy", "tag", "_value"]
|
|
36
36
|
autocomplete_fields = ["tag"]
|
|
37
|
-
list_display = ["object_id", "
|
|
37
|
+
list_display = ["object_id", "export_id", "value"]
|
|
38
38
|
readonly_fields = ["object_id"]
|
|
39
39
|
|
|
40
40
|
def has_add_permission(self, request):
|
|
@@ -67,6 +67,14 @@ def get_taxonomy(taxonomy_id: int) -> Taxonomy | None:
|
|
|
67
67
|
return taxonomy.cast() if taxonomy else None
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def get_taxonomy_by_export_id(taxonomy_export_id: str) -> Taxonomy | None:
|
|
71
|
+
"""
|
|
72
|
+
Returns a Taxonomy cast to the appropriate subclass which has the given export ID.
|
|
73
|
+
"""
|
|
74
|
+
taxonomy = Taxonomy.objects.filter(export_id=taxonomy_export_id).first()
|
|
75
|
+
return taxonomy.cast() if taxonomy else None
|
|
76
|
+
|
|
77
|
+
|
|
70
78
|
def get_taxonomies(enabled=True) -> QuerySet[Taxonomy]:
|
|
71
79
|
"""
|
|
72
80
|
Returns a queryset containing the enabled taxonomies, sorted by name.
|
|
@@ -197,7 +205,7 @@ def get_object_tags(
|
|
|
197
205
|
Value("\t"),
|
|
198
206
|
output_field=models.CharField(),
|
|
199
207
|
)))
|
|
200
|
-
.annotate(taxonomy_name=Coalesce(F("taxonomy__name"), F("
|
|
208
|
+
.annotate(taxonomy_name=Coalesce(F("taxonomy__name"), F("_export_id")))
|
|
201
209
|
# Sort first by taxonomy name, then by tag value in tree order:
|
|
202
210
|
.order_by("taxonomy_name", "sort_key")
|
|
203
211
|
)
|
|
@@ -266,12 +274,58 @@ def delete_object_tags(object_id: str):
|
|
|
266
274
|
tags.delete()
|
|
267
275
|
|
|
268
276
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
taxonomy: Taxonomy,
|
|
277
|
+
def _check_new_tag_count(
|
|
278
|
+
new_tag_count: int,
|
|
279
|
+
taxonomy: Taxonomy | None,
|
|
280
|
+
object_id: str,
|
|
281
|
+
taxonomy_export_id: str | None = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Checks if the new count of tags for the object is equal or less than 100
|
|
285
|
+
"""
|
|
286
|
+
# Exclude to avoid counting the tags that are going to be updated
|
|
287
|
+
if taxonomy:
|
|
288
|
+
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(taxonomy_id=taxonomy.id).count()
|
|
289
|
+
else:
|
|
290
|
+
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(_export_id=taxonomy_export_id).count()
|
|
291
|
+
|
|
292
|
+
if current_count + new_tag_count > 100:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
_("Cannot add more than 100 tags to ({object_id}).").format(object_id=object_id)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_current_tags(
|
|
299
|
+
taxonomy: Taxonomy | None,
|
|
272
300
|
tags: list[str],
|
|
273
301
|
object_id: str,
|
|
274
302
|
object_tag_class: type[ObjectTag] = ObjectTag,
|
|
303
|
+
taxonomy_export_id: str | None = None,
|
|
304
|
+
) -> list[ObjectTag]:
|
|
305
|
+
"""
|
|
306
|
+
Returns the current object tags of the related object_id with taxonomy
|
|
307
|
+
"""
|
|
308
|
+
ObjectTagClass = object_tag_class
|
|
309
|
+
if taxonomy:
|
|
310
|
+
if not taxonomy.allow_multiple and len(tags) > 1:
|
|
311
|
+
raise ValueError(_("Taxonomy ({name}) only allows one tag per object.").format(name=taxonomy.name))
|
|
312
|
+
current_tags = list(
|
|
313
|
+
ObjectTagClass.objects.filter(taxonomy=taxonomy, object_id=object_id)
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
current_tags = list(
|
|
317
|
+
ObjectTagClass.objects.filter(_export_id=taxonomy_export_id, object_id=object_id)
|
|
318
|
+
)
|
|
319
|
+
return current_tags
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def tag_object(
|
|
323
|
+
object_id: str,
|
|
324
|
+
taxonomy: Taxonomy | None,
|
|
325
|
+
tags: list[str],
|
|
326
|
+
object_tag_class: type[ObjectTag] = ObjectTag,
|
|
327
|
+
create_invalid: bool = False,
|
|
328
|
+
taxonomy_export_id: str | None = None,
|
|
275
329
|
) -> None:
|
|
276
330
|
"""
|
|
277
331
|
Replaces the existing ObjectTag entries for the given taxonomy + object_id
|
|
@@ -285,37 +339,34 @@ def tag_object(
|
|
|
285
339
|
Raised Tag.DoesNotExist if the proposed tags are invalid for this taxonomy.
|
|
286
340
|
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted
|
|
287
341
|
(or invalid) tags.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _check_new_tag_count(new_tag_count: int) -> None:
|
|
291
|
-
"""
|
|
292
|
-
Checks if the new count of tags for the object is equal or less than 100
|
|
293
|
-
"""
|
|
294
|
-
# Exclude self.id to avoid counting the tags that are going to be updated
|
|
295
|
-
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(taxonomy_id=taxonomy.id).count()
|
|
296
|
-
|
|
297
|
-
if current_count + new_tag_count > 100:
|
|
298
|
-
raise ValueError(
|
|
299
|
-
_("Cannot add more than 100 tags to ({object_id}).").format(object_id=object_id)
|
|
300
|
-
)
|
|
342
|
+
create_invalid: You can create invalid tags and avoid the previous behavior using.
|
|
301
343
|
|
|
344
|
+
taxonomy_export_id: You can create object tags without taxonomy using this param
|
|
345
|
+
and `taxonomy` as None. You need to use the taxonomy.export_id, so you can resync
|
|
346
|
+
this object tag if the taxonomy is created in the future.
|
|
347
|
+
"""
|
|
302
348
|
if not isinstance(tags, list):
|
|
303
349
|
raise ValueError(_("Tags must be a list, not {type}.").format(type=type(tags).__name__))
|
|
304
350
|
|
|
305
351
|
ObjectTagClass = object_tag_class
|
|
306
|
-
taxonomy = taxonomy.cast() # Make sure we're using the right subclass. This is a no-op if we are already.
|
|
307
352
|
tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order
|
|
308
353
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
raise ValueError(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
354
|
+
if taxonomy:
|
|
355
|
+
taxonomy = taxonomy.cast() # Make sure we're using the right subclass. This is a no-op if we are already.
|
|
356
|
+
elif not taxonomy_export_id:
|
|
357
|
+
raise ValueError("`taxonomy_export_id` can't be None if `taxonomy` is None")
|
|
358
|
+
|
|
359
|
+
_check_new_tag_count(len(tags), taxonomy, object_id, taxonomy_export_id)
|
|
360
|
+
current_tags = _get_current_tags(
|
|
361
|
+
taxonomy,
|
|
362
|
+
tags,
|
|
363
|
+
object_id,
|
|
364
|
+
object_tag_class,
|
|
365
|
+
taxonomy_export_id
|
|
316
366
|
)
|
|
367
|
+
|
|
317
368
|
updated_tags = []
|
|
318
|
-
if taxonomy.allow_free_text:
|
|
369
|
+
if taxonomy and taxonomy.allow_free_text:
|
|
319
370
|
for tag_value in tags:
|
|
320
371
|
object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.value == tag_value), -1)
|
|
321
372
|
if object_tag_index >= 0:
|
|
@@ -327,19 +378,46 @@ def tag_object(
|
|
|
327
378
|
else:
|
|
328
379
|
# Handle closed taxonomies:
|
|
329
380
|
for tag_value in tags:
|
|
330
|
-
tag =
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
381
|
+
tag = None
|
|
382
|
+
# When export, sometimes, the value has a space at the beginning and end.
|
|
383
|
+
tag_value = tag_value.strip()
|
|
384
|
+
if taxonomy:
|
|
385
|
+
try:
|
|
386
|
+
tag = taxonomy.tag_for_value(tag_value) # Will raise Tag.DoesNotExist if the value is invalid.
|
|
387
|
+
except Tag.DoesNotExist as e:
|
|
388
|
+
if not create_invalid:
|
|
389
|
+
raise e
|
|
390
|
+
|
|
391
|
+
if tag:
|
|
392
|
+
# Tag exists in the taxonomy
|
|
393
|
+
object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.tag_id == tag.id), -1)
|
|
394
|
+
if object_tag_index >= 0:
|
|
395
|
+
# This tag is already applied.
|
|
396
|
+
object_tag = current_tags.pop(object_tag_index)
|
|
397
|
+
if object_tag._value != tag.value: # pylint: disable=protected-access
|
|
398
|
+
# The ObjectTag's cached '_value' is out of sync with the Tag, so update it:
|
|
399
|
+
object_tag._value = tag.value # pylint: disable=protected-access
|
|
400
|
+
updated_tags.append(object_tag)
|
|
401
|
+
else:
|
|
402
|
+
# We are newly applying this tag:
|
|
403
|
+
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, tag=tag)
|
|
338
404
|
updated_tags.append(object_tag)
|
|
339
|
-
|
|
340
|
-
#
|
|
341
|
-
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id,
|
|
405
|
+
elif taxonomy:
|
|
406
|
+
# Tag doesn't exist in the taxonomy and `create_invalid` is True
|
|
407
|
+
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
|
|
342
408
|
updated_tags.append(object_tag)
|
|
409
|
+
else:
|
|
410
|
+
# Taxonomy is None (also tag doesn't exist)
|
|
411
|
+
if taxonomy_export_id:
|
|
412
|
+
# This will always be true, since it is verified at the beginning of the function.
|
|
413
|
+
# This condition is placed by the type checks.
|
|
414
|
+
object_tag = ObjectTagClass(
|
|
415
|
+
taxonomy=None,
|
|
416
|
+
object_id=object_id,
|
|
417
|
+
_value=tag_value,
|
|
418
|
+
_export_id=taxonomy_export_id
|
|
419
|
+
)
|
|
420
|
+
updated_tags.append(object_tag)
|
|
343
421
|
|
|
344
422
|
# Save all updated tags at once to avoid partial updates
|
|
345
423
|
with transaction.atomic():
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Generated by Django 3.2.22 on 2024-03-22 19:47
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
import openedx_learning.lib.fields
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def migrate_export_id(apps, schema_editor):
|
|
10
|
+
ObjectTag = apps.get_model("oel_tagging", "ObjectTag")
|
|
11
|
+
for object_tag in ObjectTag.objects.all():
|
|
12
|
+
if object_tag.taxonomy:
|
|
13
|
+
object_tag.export_id = object_tag.taxonomy.export_id
|
|
14
|
+
object_tag.save(update_fields=["_export_id"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def reverse_export_id(apps, schema_editor):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def migrate_language_export_id(apps, schema_editor):
|
|
22
|
+
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
|
|
23
|
+
language_taxonomy = Taxonomy.objects.get(id=-1)
|
|
24
|
+
language_taxonomy.export_id = 'languages-v1'
|
|
25
|
+
language_taxonomy.save(update_fields=["export_id"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reverse_language_export_id(apps, schema_editor):
|
|
29
|
+
"""
|
|
30
|
+
Return to old export_id
|
|
31
|
+
"""
|
|
32
|
+
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
|
|
33
|
+
language_taxonomy = Taxonomy.objects.get(id=-1)
|
|
34
|
+
language_taxonomy.export_id = '-1-languages'
|
|
35
|
+
language_taxonomy.save(update_fields=["export_id"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Migration(migrations.Migration):
|
|
39
|
+
|
|
40
|
+
dependencies = [
|
|
41
|
+
('oel_tagging', '0015_taxonomy_export_id'),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
operations = [
|
|
45
|
+
migrations.RenameField(
|
|
46
|
+
model_name='objecttag',
|
|
47
|
+
old_name='_name',
|
|
48
|
+
new_name='_export_id',
|
|
49
|
+
),
|
|
50
|
+
migrations.RunPython(migrate_export_id, reverse_export_id),
|
|
51
|
+
migrations.AlterField(
|
|
52
|
+
model_name='objecttag',
|
|
53
|
+
name='taxonomy',
|
|
54
|
+
field=models.ForeignKey(blank=True, default=None, help_text="Taxonomy that this object tag belongs to. Used for validating the tag and provides the tag's 'name' if set.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_tagging.taxonomy'),
|
|
55
|
+
),
|
|
56
|
+
migrations.AlterField(
|
|
57
|
+
model_name='objecttag',
|
|
58
|
+
name='_export_id',
|
|
59
|
+
field=openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text='User-facing label used for this tag, stored in case taxonomy is (or becomes) null. If the taxonomy field is set, then taxonomy.export_id takes precedence over this field.', max_length=255),
|
|
60
|
+
),
|
|
61
|
+
migrations.RunPython(migrate_language_export_id, reverse_language_export_id),
|
|
62
|
+
]
|
|
@@ -19,7 +19,7 @@ from typing_extensions import Self # Until we upgrade to python 3.11
|
|
|
19
19
|
from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field, case_sensitive_char_field
|
|
20
20
|
|
|
21
21
|
from ..data import TagDataQuerySet
|
|
22
|
-
from .utils import ConcatNull
|
|
22
|
+
from .utils import RESERVED_TAG_CHARS, ConcatNull
|
|
23
23
|
|
|
24
24
|
log = logging.getLogger(__name__)
|
|
25
25
|
|
|
@@ -191,9 +191,11 @@ class Tag(models.Model):
|
|
|
191
191
|
self.value = self.value.strip()
|
|
192
192
|
if self.external_id:
|
|
193
193
|
self.external_id = self.external_id.strip()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
|
|
195
|
+
for reserved_char in RESERVED_TAG_CHARS:
|
|
196
|
+
if reserved_char in self.value:
|
|
197
|
+
raise ValidationError(f"Tags cannot contain a '{reserved_char}' character.")
|
|
198
|
+
|
|
197
199
|
if self.external_id and "\t" in self.external_id:
|
|
198
200
|
raise ValidationError("Tag external ID cannot contain a TAB character.")
|
|
199
201
|
|
|
@@ -775,6 +777,7 @@ class ObjectTag(models.Model):
|
|
|
775
777
|
taxonomy = models.ForeignKey(
|
|
776
778
|
Taxonomy,
|
|
777
779
|
null=True,
|
|
780
|
+
blank=True,
|
|
778
781
|
default=None,
|
|
779
782
|
on_delete=models.SET_NULL,
|
|
780
783
|
help_text=_(
|
|
@@ -792,11 +795,11 @@ class ObjectTag(models.Model):
|
|
|
792
795
|
"Tag associated with this object tag. Provides the tag's 'value' if set."
|
|
793
796
|
),
|
|
794
797
|
)
|
|
795
|
-
|
|
798
|
+
_export_id = case_insensitive_char_field(
|
|
796
799
|
max_length=255,
|
|
797
800
|
help_text=_(
|
|
798
801
|
"User-facing label used for this tag, stored in case taxonomy is (or becomes) null."
|
|
799
|
-
" If the taxonomy field is set, then taxonomy.
|
|
802
|
+
" If the taxonomy field is set, then taxonomy.export_id takes precedence over this field."
|
|
800
803
|
),
|
|
801
804
|
)
|
|
802
805
|
_value = case_insensitive_char_field(
|
|
@@ -821,9 +824,9 @@ class ObjectTag(models.Model):
|
|
|
821
824
|
def __init__(self, *args, **kwargs):
|
|
822
825
|
super().__init__(*args, **kwargs)
|
|
823
826
|
if not self.pk: # This is a new instance:
|
|
824
|
-
# Set
|
|
825
|
-
if not self.
|
|
826
|
-
self.
|
|
827
|
+
# Set _export_id and _value automatically on creation, if they weren't set:
|
|
828
|
+
if not self._export_id and self.taxonomy:
|
|
829
|
+
self._export_id = self.taxonomy.export_id
|
|
827
830
|
if not self._value and self.tag:
|
|
828
831
|
self._value = self.tag.value
|
|
829
832
|
|
|
@@ -837,24 +840,28 @@ class ObjectTag(models.Model):
|
|
|
837
840
|
"""
|
|
838
841
|
User-facing string representation of an ObjectTag.
|
|
839
842
|
"""
|
|
840
|
-
|
|
843
|
+
if self.taxonomy:
|
|
844
|
+
name = self.taxonomy.name
|
|
845
|
+
else:
|
|
846
|
+
name = self.export_id
|
|
847
|
+
return f"<{self.__class__.__name__}> {self.object_id}: {name}={self.value}"
|
|
841
848
|
|
|
842
849
|
@property
|
|
843
|
-
def
|
|
850
|
+
def export_id(self) -> str:
|
|
844
851
|
"""
|
|
845
852
|
Returns this tag's name/label.
|
|
846
853
|
|
|
847
854
|
If taxonomy is set, then returns its name.
|
|
848
|
-
Otherwise, returns the cached
|
|
855
|
+
Otherwise, returns the cached _export_id field.
|
|
849
856
|
"""
|
|
850
|
-
return self.taxonomy.
|
|
857
|
+
return self.taxonomy.export_id if self.taxonomy else self._export_id
|
|
851
858
|
|
|
852
|
-
@
|
|
853
|
-
def
|
|
859
|
+
@export_id.setter
|
|
860
|
+
def export_id(self, export_id: str):
|
|
854
861
|
"""
|
|
855
|
-
Stores to the
|
|
862
|
+
Stores to the _export_id field.
|
|
856
863
|
"""
|
|
857
|
-
self.
|
|
864
|
+
self._export_id = export_id
|
|
858
865
|
|
|
859
866
|
@property
|
|
860
867
|
def value(self) -> str:
|
|
@@ -899,8 +906,11 @@ class ObjectTag(models.Model):
|
|
|
899
906
|
# was deleted, but we still preserve this _value here in case the Taxonomy or Tag get re-created in future.
|
|
900
907
|
if self._value == "":
|
|
901
908
|
raise ValidationError("Invalid _value - empty string")
|
|
902
|
-
|
|
903
|
-
|
|
909
|
+
for reserved_char in RESERVED_TAG_CHARS:
|
|
910
|
+
if reserved_char in self.value:
|
|
911
|
+
raise ValidationError(f"Invalid _value - '{reserved_char}' is not allowed")
|
|
912
|
+
if self.taxonomy and self.taxonomy.export_id != self._export_id:
|
|
913
|
+
raise ValidationError("ObjectTag's _export_id is out of sync with Taxonomy.export_id")
|
|
904
914
|
if "," in self.object_id or "*" in self.object_id:
|
|
905
915
|
# Some APIs may use these characters to allow wildcard matches or multiple matches in the future.
|
|
906
916
|
raise ValidationError("Object ID contains invalid characters")
|
|
@@ -930,11 +940,18 @@ class ObjectTag(models.Model):
|
|
|
930
940
|
# We used to have code here that would try to find a new taxonomy if the current taxonomy has been deleted.
|
|
931
941
|
# But for now that's removed, as it risks things like linking a tag to the wrong org's taxonomy.
|
|
932
942
|
|
|
933
|
-
# Sync the stored
|
|
934
|
-
if self.taxonomy and self.
|
|
935
|
-
self.
|
|
943
|
+
# Sync the stored _export_id with the taxonomy.name
|
|
944
|
+
if self.taxonomy and self._export_id != self.taxonomy.export_id:
|
|
945
|
+
self.export_id = self.taxonomy.export_id
|
|
936
946
|
changed = True
|
|
937
947
|
|
|
948
|
+
# Sync taxonomy with matching _export_id
|
|
949
|
+
if not self.taxonomy:
|
|
950
|
+
taxonomy = Taxonomy.objects.filter(export_id=self.export_id).first()
|
|
951
|
+
if taxonomy:
|
|
952
|
+
self.taxonomy = taxonomy
|
|
953
|
+
changed = True
|
|
954
|
+
|
|
938
955
|
# Closed taxonomies require a tag matching _value
|
|
939
956
|
if self.taxonomy and not self.taxonomy.allow_free_text and not self.tag_id:
|
|
940
957
|
tag = self.taxonomy.tag_set.filter(value=self.value).first()
|
|
@@ -965,5 +982,5 @@ class ObjectTag(models.Model):
|
|
|
965
982
|
self.taxonomy = object_tag.taxonomy
|
|
966
983
|
self.object_id = object_tag.object_id
|
|
967
984
|
self._value = object_tag._value # pylint: disable=protected-access
|
|
968
|
-
self.
|
|
985
|
+
self._export_id = object_tag._export_id # pylint: disable=protected-access
|
|
969
986
|
return self
|
|
@@ -4,6 +4,16 @@ Utilities for tagging and taxonomy models
|
|
|
4
4
|
from django.db.models import Aggregate, CharField
|
|
5
5
|
from django.db.models.expressions import Func
|
|
6
6
|
|
|
7
|
+
RESERVED_TAG_CHARS = [
|
|
8
|
+
'\t', # Used in the database to separate tag levels in the "lineage" field
|
|
9
|
+
# e.g. lineage="Earth\tNorth America\tMexico\tMexico City"
|
|
10
|
+
' > ', # Used in the search index and Instantsearch frontend to separate tag levels
|
|
11
|
+
# e.g. tags_level3="Earth > North America > Mexico > Mexico City"
|
|
12
|
+
';', # Used in CSV exports to separate multiple tags from the same taxonomy
|
|
13
|
+
# e.g. languages-v1: en;es;fr
|
|
14
|
+
]
|
|
15
|
+
TAGS_CSV_SEPARATOR = RESERVED_TAG_CHARS[2]
|
|
16
|
+
|
|
7
17
|
|
|
8
18
|
class ConcatNull(Func): # pylint: disable=abstract-method
|
|
9
19
|
"""
|
|
@@ -180,10 +180,11 @@ class ObjectTagsByTaxonomySerializer(UserPermissionsSerializerMixin, serializers
|
|
|
180
180
|
tax_entry = next((t for t in taxonomies if t["taxonomy_id"] == obj_tag.taxonomy_id), None)
|
|
181
181
|
if tax_entry is None:
|
|
182
182
|
tax_entry = {
|
|
183
|
-
"name": obj_tag.name,
|
|
183
|
+
"name": obj_tag.taxonomy.name if obj_tag.taxonomy else None,
|
|
184
184
|
"taxonomy_id": obj_tag.taxonomy_id,
|
|
185
185
|
"can_tag_object": self._can(can_tag_object_perm, obj_tag),
|
|
186
|
-
"tags": []
|
|
186
|
+
"tags": [],
|
|
187
|
+
"export_id": obj_tag.export_id,
|
|
187
188
|
}
|
|
188
189
|
taxonomies.append(tax_entry)
|
|
189
190
|
tax_entry["tags"].append(ObjectTagMinimalSerializer(obj_tag, context=self.context).data)
|
|
@@ -23,6 +23,7 @@ from ...api import (
|
|
|
23
23
|
get_object_tags,
|
|
24
24
|
get_taxonomies,
|
|
25
25
|
get_taxonomy,
|
|
26
|
+
resync_object_tags,
|
|
26
27
|
tag_object,
|
|
27
28
|
update_tag_in_taxonomy,
|
|
28
29
|
)
|
|
@@ -315,6 +316,8 @@ class TaxonomyView(ModelViewSet):
|
|
|
315
316
|
|
|
316
317
|
if import_success:
|
|
317
318
|
serializer = self.get_serializer(taxonomy)
|
|
319
|
+
# We need to resync all object tags because, there may be tags that do not have a taxonomy.
|
|
320
|
+
resync_object_tags()
|
|
318
321
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
319
322
|
else:
|
|
320
323
|
taxonomy.delete()
|
|
@@ -340,6 +343,8 @@ class TaxonomyView(ModelViewSet):
|
|
|
340
343
|
|
|
341
344
|
if import_success:
|
|
342
345
|
serializer = self.get_serializer(taxonomy)
|
|
346
|
+
# We need to resync all object tags because, there may be tags that do not have a taxonomy.
|
|
347
|
+
resync_object_tags()
|
|
343
348
|
return Response(serializer.data)
|
|
344
349
|
else:
|
|
345
350
|
return Response(task.log, status=status.HTTP_400_BAD_REQUEST)
|
|
@@ -549,7 +554,7 @@ class ObjectTagView(
|
|
|
549
554
|
|
|
550
555
|
tags = body.data.get("tags", [])
|
|
551
556
|
try:
|
|
552
|
-
tag_object(taxonomy, tags
|
|
557
|
+
tag_object(object_id, taxonomy, tags)
|
|
553
558
|
except TagDoesNotExist as e:
|
|
554
559
|
raise ValidationError from e
|
|
555
560
|
except ValueError as e:
|
openedx_tagging/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|