Skip to content

Commit ef4a798

Browse files
feat!: remove system-defined taxonomyies, replacing w/ read_only flag
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1bd84c3 commit ef4a798

15 files changed

Lines changed: 234 additions & 1032 deletions

File tree

src/openedx_tagging/api.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments
3737
enabled=True,
3838
allow_multiple=True,
3939
allow_free_text=False,
40-
taxonomy_class: type[Taxonomy] | None = None,
40+
read_only=False,
4141
export_id: str | None = None,
4242
) -> Taxonomy:
4343
"""
@@ -52,38 +52,34 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments
5252
enabled=enabled,
5353
allow_multiple=allow_multiple,
5454
allow_free_text=allow_free_text,
55+
read_only=read_only,
5556
export_id=export_id,
5657
)
57-
if taxonomy_class:
58-
taxonomy.taxonomy_class = taxonomy_class
5958

6059
taxonomy.full_clean()
6160
taxonomy.save()
62-
return taxonomy.cast()
61+
return taxonomy
6362

6463

6564
def get_taxonomy(taxonomy_id: int) -> Taxonomy | None:
6665
"""
67-
Returns a Taxonomy cast to the appropriate subclass which has the given ID.
66+
Returns the Taxonomy which has the given ID, or None if not found.
6867
"""
69-
taxonomy = Taxonomy.objects.filter(pk=taxonomy_id).first()
70-
return taxonomy.cast() if taxonomy else None
68+
return Taxonomy.objects.filter(pk=taxonomy_id).first()
7169

7270

7371
def get_taxonomy_by_export_id(taxonomy_export_id: str) -> Taxonomy | None:
7472
"""
75-
Returns a Taxonomy cast to the appropriate subclass which has the given export ID.
73+
Returns the Taxonomy which has the given export ID, or None if not found.
7674
"""
77-
taxonomy = Taxonomy.objects.filter(export_id=taxonomy_export_id).first()
78-
return taxonomy.cast() if taxonomy else None
75+
return Taxonomy.objects.filter(export_id=taxonomy_export_id).first()
7976

8077

8178
def get_taxonomies(enabled=True) -> QuerySet[Taxonomy]:
8279
"""
8380
Returns a queryset containing the enabled taxonomies, sorted by name.
8481
8582
We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases.
86-
So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use.
8783
8884
If you want the disabled taxonomies, pass enabled=False.
8985
If you want all taxonomies (both enabled and disabled), pass enabled=None.
@@ -101,7 +97,7 @@ def get_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
10197
Note that if the taxonomy is dynamic or free-text, only tags that have
10298
already been applied to some object will be returned.
10399
"""
104-
return taxonomy.cast().get_filtered_tags()
100+
return taxonomy.get_filtered_tags()
105101

106102

107103
def get_root_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
@@ -110,7 +106,7 @@ def get_root_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
110106
111107
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
112108
"""
113-
return taxonomy.cast().get_filtered_tags(depth=1)
109+
return taxonomy.get_filtered_tags(depth=1)
114110

115111

116112
def search_tags(
@@ -135,7 +131,7 @@ def search_tags(
135131
"_value", flat=True
136132
)
137133
)
138-
qs = taxonomy.cast().get_filtered_tags(
134+
qs = taxonomy.get_filtered_tags(
139135
search_term=search_term,
140136
excluded_values=excluded_values,
141137
)
@@ -151,7 +147,7 @@ def get_children_tags(
151147
152148
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
153149
"""
154-
return taxonomy.cast().get_filtered_tags(parent_tag_value=parent_tag_value, depth=1)
150+
return taxonomy.get_filtered_tags(parent_tag_value=parent_tag_value, depth=1)
155151

156152

157153
def resync_object_tags(object_tags: QuerySet | None = None) -> int:
@@ -354,9 +350,7 @@ def tag_object( # pylint: disable=too-many-positional-arguments
354350
ObjectTagClass = object_tag_class
355351
tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order
356352

357-
if taxonomy:
358-
taxonomy = taxonomy.cast() # Make sure we're using the right subclass. This is a no-op if we are already.
359-
elif not taxonomy_export_id:
353+
if not taxonomy and not taxonomy_export_id:
360354
raise ValueError("`taxonomy_export_id` can't be None if `taxonomy` is None")
361355

362356
_check_new_tag_count(len(tags), taxonomy, object_id, taxonomy_export_id)
@@ -444,7 +438,6 @@ def add_tag_to_taxonomy(
444438
Taxonomy, an exception is raised, otherwise the newly created
445439
Tag is returned
446440
"""
447-
taxonomy = taxonomy.cast()
448441
new_tag = taxonomy.add_tag(tag, parent_tag_value, external_id)
449442

450443
# Resync all related ObjectTags after creating new Tag to
@@ -463,7 +456,6 @@ def update_tag_in_taxonomy(taxonomy: Taxonomy, tag: str, new_value: str):
463456
464457
Currently only supports updating the Tag value.
465458
"""
466-
taxonomy = taxonomy.cast()
467459
updated_tag = taxonomy.update_tag(tag, new_value)
468460

469461
# Resync all related ObjectTags to update to the new Tag value
@@ -483,7 +475,6 @@ def delete_tags_from_taxonomy(
483475
the `with_subtags` is not set to `True` it will fail, otherwise
484476
the sub-tags will be deleted as well.
485477
"""
486-
taxonomy = taxonomy.cast()
487478
taxonomy.delete_tags(tags, with_subtags)
488479

489480

src/openedx_tagging/import_export/api.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,17 +216,16 @@ def _import_validations(taxonomy: Taxonomy):
216216
"""
217217
Validates if the taxonomy is allowed to import tags
218218
"""
219-
taxonomy = taxonomy.cast()
220219
if taxonomy.allow_free_text:
221220
raise ValueError(
222221
_(
223-
"Invalid taxonomy ({id}): You cannot import a free-form taxonomy."
222+
"Invalid taxonomy ({id}): You cannot import to a free-text taxonomy."
224223
).format(id=taxonomy.id)
225224
)
226225

227-
if taxonomy.system_defined:
226+
if taxonomy.read_only:
228227
raise ValueError(
229228
_(
230-
"Invalid taxonomy ({id}): You cannot import a system-defined taxonomy."
229+
"Invalid taxonomy ({id}): You cannot import to a read-only taxonomy."
231230
).format(id=taxonomy.id)
232231
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Remove the "system-defined" taxonomy machinery (Taxonomy subclasses and the
3+
``_taxonomy_class`` casting column) in favour of a simple ``read_only`` flag.
4+
5+
See https://github.com/openedx/openedx-core/issues/634 for the rationale.
6+
7+
This migration:
8+
9+
* Adds the ``read_only`` boolean field to Taxonomy.
10+
* Marks any taxonomy that used to be "system-defined" (i.e. had a
11+
``_taxonomy_class`` set) as ``read_only=True``, so that its tags remain
12+
immutable as they were before.
13+
* Handles the auto-created "Languages" taxonomy (``id=-1``), which was created
14+
by ``0012_language_taxonomy`` and is no longer supported: if it has been used
15+
(any related object tags exist) it is converted into a regular, editable
16+
taxonomy; otherwise it is deleted.
17+
* Removes the now-unused ``_taxonomy_class`` column and the proxy models.
18+
"""
19+
20+
from django.db import migrations, models
21+
22+
# The Languages taxonomy was auto-created with this fixed id by 0012_language_taxonomy.
23+
LANGUAGE_TAXONOMY_ID = -1
24+
25+
26+
def forwards(apps, schema_editor):
27+
"""
28+
Migrate system-defined taxonomies to the new read_only flag, and either
29+
convert or drop the auto-created Languages taxonomy.
30+
"""
31+
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
32+
ObjectTag = apps.get_model("oel_tagging", "ObjectTag")
33+
34+
language_taxonomy = Taxonomy.objects.filter(id=LANGUAGE_TAXONOMY_ID).first()
35+
if language_taxonomy:
36+
if ObjectTag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID).exists():
37+
# It's in use, so convert it into a regular, editable taxonomy,
38+
# keeping whatever language Tags have already been created.
39+
language_taxonomy._taxonomy_class = None
40+
language_taxonomy.read_only = False
41+
language_taxonomy.save()
42+
else:
43+
# Unused, so remove it (and its tags) entirely.
44+
language_taxonomy.delete()
45+
46+
# Any remaining taxonomy that was backed by a subclass was "system-defined",
47+
# meaning its tags could not be modified. Preserve that by marking it read-only.
48+
Taxonomy.objects.exclude(_taxonomy_class__isnull=True).exclude(_taxonomy_class="").update(read_only=True)
49+
50+
51+
def backwards(apps, schema_editor):
52+
"""
53+
Nothing to undo here: the deleted Languages taxonomy and the subclass
54+
information cannot be restored, because the subclasses no longer exist.
55+
The ``read_only`` column is dropped by the reversal of the AddField
56+
operation.
57+
"""
58+
59+
60+
class Migration(migrations.Migration):
61+
62+
dependencies = [
63+
('oel_tagging', '0020_tag_depth_and_lineage'),
64+
]
65+
66+
operations = [
67+
migrations.DeleteModel(
68+
name='LanguageTaxonomy',
69+
),
70+
migrations.DeleteModel(
71+
name='ModelSystemDefinedTaxonomy',
72+
),
73+
migrations.DeleteModel(
74+
name='SystemDefinedTaxonomy',
75+
),
76+
migrations.DeleteModel(
77+
name='UserSystemDefinedTaxonomy',
78+
),
79+
migrations.AddField(
80+
model_name='taxonomy',
81+
name='read_only',
82+
field=models.BooleanField(default=False, help_text='Indicates that the tags and metadata for this taxonomy are maintained by the system or an external integration; taxonomy admins will not be permitted to add, edit, or delete its tags.'),
83+
),
84+
migrations.RunPython(forwards, backwards),
85+
migrations.RemoveField(
86+
model_name='taxonomy',
87+
name='_taxonomy_class',
88+
),
89+
]

src/openedx_tagging/models/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@
33
"""
44
from .base import ObjectTag, Tag, Taxonomy
55
from .import_export import TagImportTask, TagImportTaskState
6-
from .system_defined import LanguageTaxonomy, ModelSystemDefinedTaxonomy, UserSystemDefinedTaxonomy

0 commit comments

Comments
 (0)