openedx-learning 0.1.0__py2.py3-none-any.whl → 0.1.2__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.
Files changed (25) hide show
  1. openedx_learning/__init__.py +1 -1
  2. {openedx_learning-0.1.0.dist-info → openedx_learning-0.1.2.dist-info}/METADATA +4 -3
  3. {openedx_learning-0.1.0.dist-info → openedx_learning-0.1.2.dist-info}/RECORD +25 -9
  4. openedx_tagging/core/tagging/api.py +45 -1
  5. openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml +1298 -0
  6. openedx_tagging/core/tagging/management/commands/__init__.py +0 -0
  7. openedx_tagging/core/tagging/management/commands/build_language_fixture.py +48 -0
  8. openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +76 -0
  9. openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +34 -0
  10. openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +29 -0
  11. openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +16 -0
  12. openedx_tagging/core/tagging/models/__init__.py +11 -0
  13. openedx_tagging/core/tagging/{models.py → models/base.py} +112 -24
  14. openedx_tagging/core/tagging/models/system_defined.py +269 -0
  15. openedx_tagging/core/tagging/rest_api/urls.py +9 -0
  16. openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  17. openedx_tagging/core/tagging/rest_api/v1/permissions.py +17 -0
  18. openedx_tagging/core/tagging/rest_api/v1/serializers.py +31 -0
  19. openedx_tagging/core/tagging/rest_api/v1/urls.py +14 -0
  20. openedx_tagging/core/tagging/rest_api/v1/views.py +147 -0
  21. openedx_tagging/core/tagging/rules.py +14 -15
  22. openedx_tagging/core/tagging/urls.py +10 -0
  23. {openedx_learning-0.1.0.dist-info → openedx_learning-0.1.2.dist-info}/LICENSE.txt +0 -0
  24. {openedx_learning-0.1.0.dist-info → openedx_learning-0.1.2.dist-info}/WHEEL +0 -0
  25. {openedx_learning-0.1.0.dist-info → openedx_learning-0.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,48 @@
1
+ """
2
+ Script that downloads all the ISO 639-1 languages and processes them
3
+ to write the fixture for the Language system-defined taxonomy.
4
+
5
+ This function is intended to be used only once,
6
+ but can be edited in the future if more data needs to be added to the fixture.
7
+ """
8
+ import json
9
+ import urllib.request
10
+
11
+ from django.core.management.base import BaseCommand
12
+
13
+ endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json"
14
+ output = "./openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml"
15
+
16
+
17
+ class Command(BaseCommand):
18
+ def handle(self, **options):
19
+ json_data = self.download_json()
20
+ self.build_fixture(json_data)
21
+
22
+ def download_json(self):
23
+ with urllib.request.urlopen(endpoint) as response:
24
+ json_data = response.read()
25
+ return json.loads(json_data)
26
+
27
+ def build_fixture(self, json_data):
28
+ tag_pk = -1
29
+ with open(output, "w") as output_file:
30
+ for lang_data in json_data:
31
+ lang_value = self.get_lang_value(lang_data)
32
+ lang_code = lang_data["alpha2"]
33
+ output_file.write("- model: oel_tagging.tag\n")
34
+ output_file.write(f" pk: {tag_pk}\n")
35
+ output_file.write(" fields:\n")
36
+ output_file.write(" taxonomy: -1\n")
37
+ output_file.write(" parent: null\n")
38
+ output_file.write(f" value: {lang_value}\n")
39
+ output_file.write(f" external_id: {lang_code}\n")
40
+ # System tags are identified with negative numbers to avoid clashing with user-created tags.
41
+ tag_pk -= 1
42
+
43
+ def get_lang_value(self, lang_data):
44
+ """
45
+ Gets the lang value. Some languages has many values.
46
+ """
47
+ lang_list = lang_data["English"].split(";")
48
+ return lang_list[0]
@@ -0,0 +1,76 @@
1
+ # Generated by Django 3.2.19 on 2023-07-21 17:38
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("oel_tagging", "0002_auto_20230718_2026"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.CreateModel(
13
+ name="ModelObjectTag",
14
+ fields=[],
15
+ options={
16
+ "proxy": True,
17
+ "indexes": [],
18
+ "constraints": [],
19
+ },
20
+ bases=("oel_tagging.objecttag",),
21
+ ),
22
+ migrations.CreateModel(
23
+ name="SystemDefinedTaxonomy",
24
+ fields=[],
25
+ options={
26
+ "proxy": True,
27
+ "indexes": [],
28
+ "constraints": [],
29
+ },
30
+ bases=("oel_tagging.taxonomy",),
31
+ ),
32
+ migrations.RemoveField(
33
+ model_name="taxonomy",
34
+ name="system_defined",
35
+ ),
36
+ migrations.CreateModel(
37
+ name="LanguageTaxonomy",
38
+ fields=[],
39
+ options={
40
+ "proxy": True,
41
+ "indexes": [],
42
+ "constraints": [],
43
+ },
44
+ bases=("oel_tagging.systemdefinedtaxonomy",),
45
+ ),
46
+ migrations.CreateModel(
47
+ name="ModelSystemDefinedTaxonomy",
48
+ fields=[],
49
+ options={
50
+ "proxy": True,
51
+ "indexes": [],
52
+ "constraints": [],
53
+ },
54
+ bases=("oel_tagging.systemdefinedtaxonomy",),
55
+ ),
56
+ migrations.CreateModel(
57
+ name="UserModelObjectTag",
58
+ fields=[],
59
+ options={
60
+ "proxy": True,
61
+ "indexes": [],
62
+ "constraints": [],
63
+ },
64
+ bases=("oel_tagging.modelobjecttag",),
65
+ ),
66
+ migrations.CreateModel(
67
+ name="UserSystemDefinedTaxonomy",
68
+ fields=[],
69
+ options={
70
+ "proxy": True,
71
+ "indexes": [],
72
+ "constraints": [],
73
+ },
74
+ bases=("oel_tagging.modelsystemdefinedtaxonomy",),
75
+ ),
76
+ ]
@@ -0,0 +1,34 @@
1
+ # Generated by Django 3.2.19 on 2023-07-24 06:25
2
+
3
+ from django.db import migrations, models
4
+ import openedx_learning.lib.fields
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("oel_tagging", "0003_auto_20230721_1238"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="objecttag",
15
+ name="object_id",
16
+ field=openedx_learning.lib.fields.MultiCollationCharField(
17
+ db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"},
18
+ db_index=True,
19
+ editable=False,
20
+ help_text="Identifier for the object being tagged",
21
+ max_length=255,
22
+ ),
23
+ ),
24
+ migrations.AlterUniqueTogether(
25
+ name="tag",
26
+ unique_together={("taxonomy", "external_id"), ("taxonomy", "value")},
27
+ ),
28
+ migrations.AddIndex(
29
+ model_name="objecttag",
30
+ index=models.Index(
31
+ fields=["taxonomy", "object_id"], name="oel_tagging_taxonom_aa24e6_idx"
32
+ ),
33
+ ),
34
+ ]
@@ -0,0 +1,29 @@
1
+ # Generated by Django 3.2.19 on 2023-07-28 13:33
2
+
3
+ from django.db import migrations
4
+ from django.core.management import call_command
5
+
6
+
7
+ def load_language_taxonomy(apps, schema_editor):
8
+ """
9
+ Load language taxonomy and tags
10
+ """
11
+ call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml")
12
+
13
+
14
+ def revert(apps, schema_editor):
15
+ """
16
+ Deletes language taxonomy an tags
17
+ """
18
+ Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
19
+ Taxonomy.objects.filter(id=-1).delete()
20
+
21
+
22
+ class Migration(migrations.Migration):
23
+ dependencies = [
24
+ ("oel_tagging", "0004_auto_20230723_2001"),
25
+ ]
26
+
27
+ operations = [
28
+ migrations.RunPython(load_language_taxonomy, revert),
29
+ ]
@@ -0,0 +1,16 @@
1
+ # Generated by Django 3.2.19 on 2023-08-02 16:20
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("oel_tagging", "0005_language_taxonomy"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterUniqueTogether(
13
+ name="objecttag",
14
+ unique_together={("taxonomy", "_value", "object_id")},
15
+ ),
16
+ ]
@@ -0,0 +1,11 @@
1
+ from .base import (
2
+ Tag,
3
+ Taxonomy,
4
+ ObjectTag,
5
+ )
6
+ from .system_defined import (
7
+ ModelObjectTag,
8
+ ModelSystemDefinedTaxonomy,
9
+ UserSystemDefinedTaxonomy,
10
+ LanguageTaxonomy,
11
+ )
@@ -1,4 +1,4 @@
1
- """ Tagging app data models """
1
+ """ Tagging app base data models """
2
2
  import logging
3
3
  from typing import List, Type, Union
4
4
 
@@ -6,7 +6,10 @@ from django.db import models
6
6
  from django.utils.module_loading import import_string
7
7
  from django.utils.translation import gettext_lazy as _
8
8
 
9
- from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field
9
+ from openedx_learning.lib.fields import (
10
+ MultiCollationTextField,
11
+ case_insensitive_char_field,
12
+ )
10
13
 
11
14
  log = logging.getLogger(__name__)
12
15
 
@@ -66,6 +69,10 @@ class Tag(models.Model):
66
69
  models.Index(fields=["taxonomy", "value"]),
67
70
  models.Index(fields=["taxonomy", "external_id"]),
68
71
  ]
72
+ unique_together = [
73
+ ["taxonomy", "external_id"],
74
+ ["taxonomy", "value"],
75
+ ]
69
76
 
70
77
  def __repr__(self):
71
78
  """
@@ -140,14 +147,6 @@ class Taxonomy(models.Model):
140
147
  "Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values."
141
148
  ),
142
149
  )
143
- system_defined = models.BooleanField(
144
- default=False,
145
- editable=False,
146
- help_text=_(
147
- "Indicates that tags and metadata for this taxonomy are maintained by the system;"
148
- " taxonomy admins will not be permitted to modify them.",
149
- ),
150
- )
151
150
  visible_to_authors = models.BooleanField(
152
151
  default=True,
153
152
  editable=False,
@@ -177,8 +176,25 @@ class Taxonomy(models.Model):
177
176
  """
178
177
  User-facing string representation of a Taxonomy.
179
178
  """
179
+ try:
180
+ if self._taxonomy_class:
181
+ return f"<{self.taxonomy_class.__name__}> ({self.id}) {self.name}"
182
+ except ImportError:
183
+ # Log error and continue
184
+ log.exception(
185
+ f"Unable to import taxonomy_class for {self.id}: {self._taxonomy_class}"
186
+ )
180
187
  return f"<{self.__class__.__name__}> ({self.id}) {self.name}"
181
188
 
189
+ @property
190
+ def object_tag_class(self) -> Type:
191
+ """
192
+ Returns the ObjectTag subclass associated with this taxonomy, which is ObjectTag by default.
193
+
194
+ Taxonomy subclasses may override this method to use different subclasses of ObjectTag.
195
+ """
196
+ return ObjectTag
197
+
182
198
  @property
183
199
  def taxonomy_class(self) -> Type:
184
200
  """
@@ -190,6 +206,14 @@ class Taxonomy(models.Model):
190
206
  return import_string(self._taxonomy_class)
191
207
  return None
192
208
 
209
+ @property
210
+ def system_defined(self) -> bool:
211
+ """
212
+ Indicates that tags and metadata for this taxonomy are maintained by the system;
213
+ taxonomy admins will not be permitted to modify them.
214
+ """
215
+ return False
216
+
193
217
  @taxonomy_class.setter
194
218
  def taxonomy_class(self, taxonomy_class: Union[Type, None]):
195
219
  """
@@ -239,15 +263,16 @@ class Taxonomy(models.Model):
239
263
  self.required = taxonomy.required
240
264
  self.allow_multiple = taxonomy.allow_multiple
241
265
  self.allow_free_text = taxonomy.allow_free_text
242
- self.system_defined = taxonomy.system_defined
243
266
  self.visible_to_authors = taxonomy.visible_to_authors
244
267
  self._taxonomy_class = taxonomy._taxonomy_class
245
268
  return self
246
269
 
247
- def get_tags(self) -> List[Tag]:
270
+ def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]:
248
271
  """
249
272
  Returns a list of all Tags in the current taxonomy, from the root(s) down to TAXONOMY_MAX_DEPTH tags, in tree order.
250
273
 
274
+ Use `tag_set` to do an initial filtering of the tags.
275
+
251
276
  Annotates each returned Tag with its ``depth`` in the tree (starting at 0).
252
277
 
253
278
  Performance note: may perform as many as TAXONOMY_MAX_DEPTH select queries.
@@ -256,9 +281,12 @@ class Taxonomy(models.Model):
256
281
  if self.allow_free_text:
257
282
  return tags
258
283
 
284
+ if tag_set is None:
285
+ tag_set = self.tag_set
286
+
259
287
  parents = None
260
288
  for depth in range(TAXONOMY_MAX_DEPTH):
261
- filtered_tags = self.tag_set.prefetch_related("parent")
289
+ filtered_tags = tag_set.prefetch_related("parent")
262
290
  if parents is None:
263
291
  filtered_tags = filtered_tags.filter(parent=None)
264
292
  else:
@@ -366,9 +394,10 @@ class Taxonomy(models.Model):
366
394
  _(f"Taxonomy ({self.id}) requires at least one tag per object.")
367
395
  )
368
396
 
397
+ ObjectTagClass = self.object_tag_class
369
398
  current_tags = {
370
399
  tag.tag_ref: tag
371
- for tag in ObjectTag.objects.filter(
400
+ for tag in ObjectTagClass.objects.filter(
372
401
  taxonomy=self,
373
402
  object_id=object_id,
374
403
  )
@@ -378,20 +407,12 @@ class Taxonomy(models.Model):
378
407
  if tag_ref in current_tags:
379
408
  object_tag = current_tags.pop(tag_ref)
380
409
  else:
381
- object_tag = ObjectTag(
410
+ object_tag = ObjectTagClass(
382
411
  taxonomy=self,
383
412
  object_id=object_id,
384
413
  )
385
414
 
386
- try:
387
- object_tag.tag = self.tag_set.get(
388
- id=tag_ref,
389
- )
390
- except (ValueError, Tag.DoesNotExist):
391
- # This might be ok, e.g. if self.allow_free_text.
392
- # We'll validate below before saving.
393
- object_tag.value = tag_ref
394
-
415
+ object_tag.tag_ref = tag_ref
395
416
  object_tag.resync()
396
417
  if not self.validate_object_tag(object_tag):
397
418
  raise ValueError(
@@ -409,6 +430,52 @@ class Taxonomy(models.Model):
409
430
 
410
431
  return updated_tags
411
432
 
433
+ def autocomplete_tags(
434
+ self,
435
+ search: str,
436
+ object_id: str = None,
437
+ ) -> models.QuerySet:
438
+ """
439
+ Provides auto-complete suggestions by matching the `search` string against existing
440
+ ObjectTags linked to the given taxonomy. A case-insensitive search is used in order
441
+ to return the highest number of relevant tags.
442
+
443
+ If `object_id` is provided, then object tag values already linked to this object
444
+ are omitted from the returned suggestions. (ObjectTag values must be unique for a
445
+ given object + taxonomy, and so omitting these suggestions helps users avoid
446
+ duplication errors.).
447
+
448
+ Returns a QuerySet of dictionaries containing distinct `value` (string) and `tag`
449
+ (numeric ID) values, sorted alphabetically by `value`.
450
+
451
+ Subclasses can override this method to perform their own autocomplete process.
452
+ Subclass use cases:
453
+ * Large taxonomy associated with a model. It can be overridden to get
454
+ the suggestions directly from the model by doing own filtering.
455
+ * Taxonomy with a list of available tags: It can be overridden to only
456
+ search the suggestions on a list of available tags.
457
+ """
458
+ # Fetch tags that the object already has to exclude them from the result
459
+ excluded_tags = []
460
+ if object_id:
461
+ excluded_tags = self.objecttag_set.filter(object_id=object_id).values_list(
462
+ "_value", flat=True
463
+ )
464
+ return (
465
+ # Fetch object tags from this taxonomy whose value contains the search
466
+ self.objecttag_set.filter(_value__icontains=search)
467
+ # omit any tags whose values match the tags on the given object
468
+ .exclude(_value__in=excluded_tags)
469
+ # alphabetical ordering
470
+ .order_by("_value")
471
+ # Alias the `_value` field to `value` to make it nicer for users
472
+ .annotate(value=models.F("_value"))
473
+ # obtain tag values
474
+ .values("value", "tag_id")
475
+ # remove repeats
476
+ .distinct()
477
+ )
478
+
412
479
 
413
480
  class ObjectTag(models.Model):
414
481
  """
@@ -431,6 +498,7 @@ class ObjectTag(models.Model):
431
498
  id = models.BigAutoField(primary_key=True)
432
499
  object_id = case_insensitive_char_field(
433
500
  max_length=255,
501
+ db_index=True,
434
502
  editable=False,
435
503
  help_text=_("Identifier for the object being tagged"),
436
504
  )
@@ -472,8 +540,10 @@ class ObjectTag(models.Model):
472
540
 
473
541
  class Meta:
474
542
  indexes = [
543
+ models.Index(fields=["taxonomy", "object_id"]),
475
544
  models.Index(fields=["taxonomy", "_value"]),
476
545
  ]
546
+ unique_together = ("taxonomy", "_value", "object_id")
477
547
 
478
548
  def __repr__(self):
479
549
  """
@@ -531,6 +601,24 @@ class ObjectTag(models.Model):
531
601
  """
532
602
  return self.tag.id if self.tag_id else self._value
533
603
 
604
+ @tag_ref.setter
605
+ def tag_ref(self, tag_ref: str):
606
+ """
607
+ Sets the ObjectTag's Tag and/or value, depending on whether a valid Tag is found.
608
+
609
+ Subclasses may override this method to dynamically create Tags.
610
+ """
611
+ self.value = tag_ref
612
+
613
+ if self.taxonomy_id:
614
+ try:
615
+ self.tag = self.taxonomy.tag_set.get(pk=tag_ref)
616
+ self.value = self.tag.value
617
+ except (ValueError, Tag.DoesNotExist):
618
+ # This might be ok, e.g. if our taxonomy.allow_free_text, so we just pass through here.
619
+ # We rely on the caller to validate before saving.
620
+ pass
621
+
534
622
  def is_valid(self) -> bool:
535
623
  """
536
624
  Returns True if this ObjectTag represents a valid taxonomy tag.