Skip to content

Commit a7661bb

Browse files
committed
refactor: allows Taxonomy to be subclassed
* adds optional taxonomy_class property+field to Taxonomy * adds Taxonomy cast() method to use this class * oel_tagging.api uses Taxonomy.cast() whenever practical * moves ObjectTag validation back to Taxonomy * removes ObjectTag.resync() logic -- we don't need it yet. * removes ObjectTag.object_type field -- we're not using it for anything. * squashes migrations from previous commits
1 parent bbbb4cc commit a7661bb

File tree

12 files changed

+438
-626
lines changed

12 files changed

+438
-626
lines changed

docs/decisions/0007-tagging-app.rst

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ Taxonomy
1919

2020
The ``openedx_tagging`` module defines ``openedx_tagging.core.models.Taxonomy``, whose data and functionality are self-contained to the ``openedx_tagging`` app. However in Studio, we need to be able to limit access to some Taxonomy by organization, using the same "course creator" access which limits course creation for an organization to a defined set of users.
2121

22-
So in edx-platform, we will create the ``openedx.features.content_tagging`` app, to contain the models and logic for linking Organization owners to Taxonomies. There's no need to subclass Taxonomy here; we can enforce access using the ``content_tagging.api``.
22+
So in edx-platform, we will create the ``openedx.features.content_tagging`` app, to contain the models and logic for linking Organization owners to Taxonomies. Here, we can subclass ``Taxonomy`` as needed, preferably using proxy models. The APIs are responsible for ensuring that any ``Taxonomy`` instances are cast to the appropriate subclass.
2323

2424
ObjectTag
2525
~~~~~~~~~
2626

2727
Similarly, the ``openedx_tagging`` module defined ``openedx_tagging.core.models.ObjectTag``, also self-contained to the
2828
``openedx_tagging`` app.
2929

30-
But to tag content in the LMS/Studio, we need to enforce ``object_id`` as a CourseKey or UsageKey type. So to do this, we subclass ``ObjectTag``, and use the ``openedx.features.tagging.registry`` to register the subclass, so that it will be picked up when this tagging API creates or resyncs object tags.
30+
But to tag content in the LMS/Studio, we need to enforce ``object_id`` as a CourseKey or UsageKey type. So to do this, we subclass ``ObjectTag``, and use this class when creating object tags for the content taxonomies. Once the ``object_id`` is set, it is not editable, and so this key validation need not happen again.
3131

3232
Rejected Alternatives
3333
---------------------
@@ -38,14 +38,3 @@ Embed in edx-platform
3838
Embedding the logic in edx-platform would provide the content tagging logic specifically required for the MVP.
3939

4040
However, we plan to extend tagging to other object types (e.g. People) and contexts (e.g. Marketing), and so a generic, standalone library is preferable in the log run.
41-
42-
43-
Subclass Taxonomy
44-
~~~~~~~~~~~~~~~~~
45-
46-
Another approach considered was to encapsulate the complexity of ObjectTag validation in the Taxonomy class, and so use subclasses of Taxonomy to validate different types of ObjectTags, and use `multi-table inheritance`_ and django polymorphism when pulling the taxonomies from the database.
47-
48-
However, because we will have a number of different types of Taxonomies, this proved non-performant.
49-
50-
51-
-.. _multi-table inheritance: https://docs.djangoproject.com/en/3.2/topics/db/models/#multi-table-inheritance

openedx_tagging/core/tagging/api.py

Lines changed: 17 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -25,31 +25,40 @@ def create_taxonomy(
2525
required=False,
2626
allow_multiple=False,
2727
allow_free_text=False,
28+
taxonomy_class: Type = None,
2829
) -> Taxonomy:
2930
"""
3031
Creates, saves, and returns a new Taxonomy with the given attributes.
3132
"""
32-
taxonomy = Taxonomy.objects.create(
33+
taxonomy = Taxonomy(
3334
name=name,
3435
description=description,
3536
enabled=enabled,
3637
required=required,
3738
allow_multiple=allow_multiple,
3839
allow_free_text=allow_free_text,
3940
)
40-
return taxonomy
41+
if taxonomy_class:
42+
taxonomy.taxonomy_class = taxonomy_class
43+
taxonomy.save()
44+
return taxonomy.cast()
4145

4246

4347
def get_taxonomy(id: int) -> Union[Taxonomy, None]:
4448
"""
45-
Returns a Taxonomy of the appropriate subclass which has the given ID.
49+
Returns a Taxonomy cast to the appropriate subclass which has the given ID.
4650
"""
47-
return Taxonomy.objects.filter(id=id).first()
51+
taxonomy = Taxonomy.objects.filter(id=id).first()
52+
return taxonomy.cast() if taxonomy else None
4853

4954

5055
def get_taxonomies(enabled=True) -> QuerySet:
5156
"""
5257
Returns a queryset containing the enabled taxonomies, sorted by name.
58+
59+
We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases.
60+
So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use.
61+
5362
If you want the disabled taxonomies, pass enabled=False.
5463
If you want all taxonomies (both enabled and disabled), pass enabled=None.
5564
"""
@@ -65,37 +74,11 @@ def get_tags(taxonomy: Taxonomy) -> List[Tag]:
6574
6675
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
6776
"""
68-
return taxonomy.get_tags()
69-
70-
71-
def cast_object_tag(object_tag: ObjectTag) -> ObjectTag:
72-
"""
73-
Casts/copies the given object tag data into the ObjectTag subclass most appropriate for this tag.
74-
"""
75-
return object_tag.cast_object_tag()
76-
77-
78-
def resync_object_tags(object_tags: QuerySet = None) -> int:
79-
"""
80-
Reconciles ObjectTag entries with any changes made to their associated taxonomies and tags.
81-
82-
By default, we iterate over all ObjectTags. Pass a filtered ObjectTags queryset to limit which tags are resynced.
83-
"""
84-
if not object_tags:
85-
object_tags = ObjectTag.objects.select_related("tag", "taxonomy")
86-
87-
num_changed = 0
88-
for tag in object_tags:
89-
object_tag = cast_object_tag(tag)
90-
changed = object_tag.resync()
91-
if changed:
92-
object_tag.save()
93-
num_changed += 1
94-
return num_changed
77+
return taxonomy.cast().get_tags()
9578

9679

9780
def get_object_tags(
98-
object_id: str, object_type: str = None, taxonomy: Taxonomy = None, valid_only=True
81+
object_id: str, taxonomy: Taxonomy = None, valid_only=True
9982
) -> Iterator[ObjectTag]:
10083
"""
10184
Generates a list of object tags for a given object.
@@ -112,13 +95,10 @@ def get_object_tags(
11295
.select_related("tag", "taxonomy")
11396
.order_by("id")
11497
)
115-
if object_type:
116-
tags = tags.filter(object_type=object_type)
11798
if taxonomy:
11899
tags = tags.filter(taxonomy=taxonomy)
119100

120-
for tag in tags:
121-
object_tag = cast_object_tag(tag)
101+
for object_tag in tags:
122102
if not valid_only or object_tag.is_valid():
123103
yield object_tag
124104

@@ -127,8 +107,6 @@ def tag_object(
127107
taxonomy: Taxonomy,
128108
tags: List,
129109
object_id: str,
130-
object_type: str,
131-
object_tag_class: Type = None,
132110
) -> List[ObjectTag]:
133111
"""
134112
Replaces the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags.
@@ -139,58 +117,4 @@ def tag_object(
139117
Raised ValueError if the proposed tags are invalid for this taxonomy.
140118
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags.
141119
"""
142-
143-
if not taxonomy.allow_multiple and len(tags) > 1:
144-
raise ValueError(_(f"Taxonomy ({taxonomy.id}) only allows one tag per object."))
145-
146-
if taxonomy.required and len(tags) == 0:
147-
raise ValueError(
148-
_(f"Taxonomy ({taxonomy.id}) requires at least one tag per object.")
149-
)
150-
151-
current_tags = {
152-
tag.tag_ref: tag
153-
for tag in ObjectTag.objects.filter(
154-
taxonomy=taxonomy, object_id=object_id, object_type=object_type
155-
)
156-
}
157-
updated_tags = []
158-
for tag_ref in tags:
159-
if tag_ref in current_tags:
160-
object_tag = cast_object_tag(current_tags.pop(tag_ref))
161-
else:
162-
try:
163-
tag = taxonomy.tag_set.get(
164-
id=tag_ref,
165-
)
166-
value = tag.value
167-
except (ValueError, Tag.DoesNotExist):
168-
# This might be ok, e.g. if taxonomy.allow_free_text.
169-
# We'll validate below before saving.
170-
tag = None
171-
value = tag_ref
172-
173-
object_tag = ObjectTag(
174-
taxonomy=taxonomy,
175-
object_id=object_id,
176-
object_type=object_type,
177-
tag=tag,
178-
value=value,
179-
name=taxonomy.name,
180-
)
181-
if object_tag_class:
182-
object_tag.object_tag_class = object_tag_class
183-
object_tag = cast_object_tag(object_tag)
184-
185-
object_tag.resync()
186-
updated_tags.append(object_tag)
187-
188-
# Save all updated tags at once to avoid partial updates
189-
for object_tag in updated_tags:
190-
object_tag.save()
191-
192-
# ...and delete any omitted existing tags
193-
for old_tag in current_tags.values():
194-
old_tag.delete()
195-
196-
return updated_tags
120+
return taxonomy.cast().tag_object(tags, object_id)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Generated by Django 3.2.19 on 2023-07-18 05:54
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
import openedx_learning.lib.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("oel_tagging", "0001_initial"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="taxonomy",
17+
name="system_defined",
18+
field=models.BooleanField(
19+
default=False,
20+
editable=False,
21+
help_text="Indicates that tags and metadata for this taxonomy are maintained by the system; taxonomy admins will not be permitted to modify them.",
22+
),
23+
),
24+
migrations.AlterField(
25+
model_name="tag",
26+
name="parent",
27+
field=models.ForeignKey(
28+
default=None,
29+
help_text="Tag that lives one level up from the current tag, forming a hierarchy.",
30+
null=True,
31+
on_delete=django.db.models.deletion.CASCADE,
32+
related_name="children",
33+
to="oel_tagging.tag",
34+
),
35+
),
36+
migrations.AlterField(
37+
model_name="tag",
38+
name="taxonomy",
39+
field=models.ForeignKey(
40+
default=None,
41+
help_text="Namespace and rules for using a given set of tags.",
42+
null=True,
43+
on_delete=django.db.models.deletion.CASCADE,
44+
to="oel_tagging.taxonomy",
45+
),
46+
),
47+
migrations.AddField(
48+
model_name="taxonomy",
49+
name="visible_to_authors",
50+
field=models.BooleanField(
51+
default=True,
52+
editable=False,
53+
help_text="Indicates whether this taxonomy should be visible to object authors.",
54+
),
55+
),
56+
migrations.RemoveField(
57+
model_name="objecttag",
58+
name="object_type",
59+
),
60+
migrations.AddField(
61+
model_name="taxonomy",
62+
name="_taxonomy_class",
63+
field=models.CharField(
64+
help_text="Taxonomy subclass used to instantiate this instance.Must be a fully-qualified module and class name.",
65+
max_length=255,
66+
null=True,
67+
),
68+
),
69+
migrations.AlterField(
70+
model_name="objecttag",
71+
name="object_id",
72+
field=openedx_learning.lib.fields.MultiCollationCharField(
73+
db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"},
74+
editable=False,
75+
help_text="Identifier for the object being tagged",
76+
max_length=255,
77+
),
78+
),
79+
]

openedx_tagging/core/tagging/migrations/0002_taxonomy_system_defined.py

Lines changed: 0 additions & 20 deletions
This file was deleted.

openedx_tagging/core/tagging/migrations/0003_objecttag_proxies.py

Lines changed: 0 additions & 41 deletions
This file was deleted.

openedx_tagging/core/tagging/migrations/0004_tag_cascade_delete.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)