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.
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.6.2"
4
+ __version__ = "0.8.0"
File without changes
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.6.2
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
- Requires-Dist: celery
21
- Requires-Dist: attrs
22
- Requires-Dist: rules (<4.0)
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=Uxw5oLTw8J4jhitEbqxxCFFiwAF-KHiHtkJlIwZBBew,67
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=5mSTxlftMq5MVGzdE8xl3AcxgtfpnrKpXPNdvw7hAno,1075
50
- openedx_tagging/core/tagging/api.py,sha256=jXmu2WPFDEiLKpz0OqVR8yo7NyKTo3omlRkytwa__L8,15758
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=8RrvrmX5tTHbMNibzzHZbEu97XP7HHVPEVNlvPT2tZU,38623
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=JD3ypABgEdx5lQBttFm0ByfgHJPuOQiJ8HYvUgAMA8g,1417
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=NBRz7FkcNVf_rbvaf859TLDR1FqbCDuBtczgbt1DbGU,13294
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=7_B9DAoMz7gFQDPCsiX_aThc-YG9S4AjLipObyYNDk0,34464
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.6.2.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
98
- openedx_learning-0.6.2.dist-info/METADATA,sha256=CE9dS9_ExpRV7Ha8sgyeJxctbLzEsMV7HEdDdZ8GGIw,8758
99
- openedx_learning-0.6.2.dist-info/WHEEL,sha256=Z-nyYpwrcSqxfdux5Mbn_DQ525iP7J2DG3JgGvOYyTQ,110
100
- openedx_learning-0.6.2.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
101
- openedx_learning-0.6.2.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.36.2)
2
+ Generator: bdist_wheel (0.43.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any
@@ -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", "name", "value"]
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("_name")))
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
- # TODO: a function called "tag_object" should take "object_id" as its first parameter, not taxonomy
270
- def tag_object(
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
- _check_new_tag_count(len(tags))
310
-
311
- if not taxonomy.allow_multiple and len(tags) > 1:
312
- raise ValueError(_("Taxonomy ({name}) only allows one tag per object.").format(name=taxonomy.name))
313
-
314
- current_tags = list(
315
- ObjectTagClass.objects.filter(taxonomy=taxonomy, object_id=object_id)
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 = taxonomy.tag_for_value(tag_value) # Will raise Tag.DoesNotExist if the value is invalid.
331
- object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.tag_id == tag.id), -1)
332
- if object_tag_index >= 0:
333
- # This tag is already applied.
334
- object_tag = current_tags.pop(object_tag_index)
335
- if object_tag._value != tag.value: # pylint: disable=protected-access
336
- # The ObjectTag's cached '_value' is out of sync with the Tag, so update it:
337
- object_tag._value = tag.value # pylint: disable=protected-access
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
- else:
340
- # We are newly applying this tag:
341
- object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, tag=tag)
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
- # Don't allow \t (tab) character at all, as we use it for lineage in database queries
195
- if "\t" in self.value:
196
- raise ValidationError("Tags in a taxonomy cannot contain a TAB character.")
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
- _name = case_insensitive_char_field(
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.name takes precedence over this field."
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 _name and _value automatically on creation, if they weren't set:
825
- if not self._name and self.taxonomy:
826
- self._name = self.taxonomy.name
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
- return f"<{self.__class__.__name__}> {self.object_id}: {self.name}={self.value}"
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 name(self) -> str:
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 _name field.
855
+ Otherwise, returns the cached _export_id field.
849
856
  """
850
- return self.taxonomy.name if self.taxonomy else self._name
857
+ return self.taxonomy.export_id if self.taxonomy else self._export_id
851
858
 
852
- @name.setter
853
- def name(self, name: str):
859
+ @export_id.setter
860
+ def export_id(self, export_id: str):
854
861
  """
855
- Stores to the _name field.
862
+ Stores to the _export_id field.
856
863
  """
857
- self._name = name
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
- if self.taxonomy and self.taxonomy.name != self._name:
903
- raise ValidationError("ObjectTag's _name is out of sync with Taxonomy.name")
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 _name with the taxonomy.name
934
- if self.taxonomy and self._name != self.taxonomy.name:
935
- self.name = self.taxonomy.name
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._name = object_tag._name # pylint: disable=protected-access
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, object_id)
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:
File without changes