Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/openedx_tagging/decisions/0007-system-taxonomy-creation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
7. System-defined Taxonomy & Tags creation
============================================

Status
------

Obsolete ⚠️. The "system-defined" attribute and built-in taxonomies have been removed.

Context
--------

Expand Down Expand Up @@ -65,3 +70,10 @@ Tags hard-coded by fixtures/migrations
In the future there may be system-defined taxonomies that are not dynamics at
all, where the list of tags are defined by ``Tag`` instances created by a
fixture or migration. However, as of now we don't have a use case for that.

Changelog
---------

2026-07-29:

* Updated "Status": this ADR is obsolete.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
8. System-defined automatic tagging
=====================================

Status
------

Obsolete ⚠️. The "system-defined" attribute and "automatic tagging" feature have been removed.

Context
--------

Expand Down Expand Up @@ -51,3 +56,10 @@ auto tagging context. The `hooks documentation`_ suggests the use of `events`_ h
.. _hooks documentation: https://github.com/openedx/edx-platform/blob/master/docs/guides/hooks/index.rst
.. _events: https://github.com/openedx/edx-platform/blob/master/docs/guides/hooks/events.rst
.. _a receiver: https://github.com/openedx/edx-platform/blob/master/docs/guides/hooks/events.rst#receiving-events

Changelog
---------

2026-07-29:

* Updated "Status": this ADR is obsolete.
56 changes: 19 additions & 37 deletions src/openedx_tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments
enabled=True,
allow_multiple=True,
allow_free_text=False,
taxonomy_class: type[Taxonomy] | None = None,
read_only=False,
export_id: str | None = None,
) -> Taxonomy:
"""
Expand All @@ -52,38 +52,34 @@ def create_taxonomy( # pylint: disable=too-many-positional-arguments
enabled=enabled,
allow_multiple=allow_multiple,
allow_free_text=allow_free_text,
read_only=read_only,
export_id=export_id,
)
if taxonomy_class:
taxonomy.taxonomy_class = taxonomy_class

taxonomy.full_clean()
taxonomy.save()
return taxonomy.cast()
return taxonomy


def get_taxonomy(taxonomy_id: int) -> Taxonomy | None:
"""
Returns a Taxonomy cast to the appropriate subclass which has the given ID.
Returns the Taxonomy which has the given ID, or None if not found.
"""
taxonomy = Taxonomy.objects.filter(pk=taxonomy_id).first()
return taxonomy.cast() if taxonomy else None
return Taxonomy.objects.filter(pk=taxonomy_id).first()


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


def get_taxonomies(enabled=True) -> QuerySet[Taxonomy]:
"""
Returns a queryset containing the enabled taxonomies, sorted by name.

We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases.
So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use.

If you want the disabled taxonomies, pass enabled=False.
If you want all taxonomies (both enabled and disabled), pass enabled=None.
Expand All @@ -101,7 +97,7 @@ def get_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
Note that if the taxonomy is dynamic or free-text, only tags that have
already been applied to some object will be returned.
"""
return taxonomy.cast().get_filtered_tags()
return taxonomy.get_filtered_tags()


def get_root_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
Expand All @@ -110,7 +106,7 @@ def get_root_tags(taxonomy: Taxonomy) -> TagDataQuerySet:

Note that if the taxonomy allows free-text tags, then the returned list will be empty.
"""
return taxonomy.cast().get_filtered_tags(depth=1)
return taxonomy.get_filtered_tags(depth=1)


def search_tags(
Expand All @@ -135,7 +131,7 @@ def search_tags(
"_value", flat=True
)
)
qs = taxonomy.cast().get_filtered_tags(
qs = taxonomy.get_filtered_tags(
search_term=search_term,
excluded_values=excluded_values,
)
Expand All @@ -151,7 +147,7 @@ def get_children_tags(

Note that if the taxonomy allows free-text tags, then the returned list will be empty.
"""
return taxonomy.cast().get_filtered_tags(parent_tag_value=parent_tag_value, depth=1)
return taxonomy.get_filtered_tags(parent_tag_value=parent_tag_value, depth=1)


def resync_object_tags(object_tags: QuerySet | None = None) -> int:
Expand All @@ -177,7 +173,6 @@ def get_object_tags(
object_id: str,
taxonomy_id: int | None = None,
include_deleted: bool = False,
object_tag_class: type[ObjectTag] = ObjectTag
) -> QuerySet[ObjectTag]:
"""
Returns a Queryset of object tags for a given object.
Expand All @@ -186,7 +181,7 @@ def get_object_tags(
"""
filters = {"taxonomy_id": taxonomy_id} if taxonomy_id else {}
base_qs = (
object_tag_class.objects
ObjectTag.objects
.filter(object_id=object_id, **filters)
.exclude(taxonomy__enabled=False) # Exclude if the whole taxonomy is disabled
)
Expand Down Expand Up @@ -302,22 +297,20 @@ def _get_current_tags(
taxonomy: Taxonomy | None,
tags: list[str],
object_id: str,
object_tag_class: type[ObjectTag] = ObjectTag,
taxonomy_export_id: str | None = None,
) -> list[ObjectTag]:
"""
Returns the current object tags of the related object_id with taxonomy
"""
ObjectTagClass = object_tag_class
if taxonomy:
if not taxonomy.allow_multiple and len(tags) > 1:
raise ValueError(_("Taxonomy ({name}) only allows one tag per object.").format(name=taxonomy.name))
current_tags = list(
ObjectTagClass.objects.filter(taxonomy=taxonomy, object_id=object_id)
ObjectTag.objects.filter(taxonomy=taxonomy, object_id=object_id)
)
else:
current_tags = list(
ObjectTagClass.objects.filter(_export_id=taxonomy_export_id, object_id=object_id)
ObjectTag.objects.filter(_export_id=taxonomy_export_id, object_id=object_id)
)
return current_tags

Expand All @@ -326,7 +319,6 @@ def tag_object( # pylint: disable=too-many-positional-arguments
object_id: str,
taxonomy: Taxonomy | None,
tags: list[str],
object_tag_class: type[ObjectTag] = ObjectTag,
create_invalid: bool = False,
taxonomy_export_id: str | None = None,
) -> None:
Expand All @@ -336,9 +328,6 @@ def tag_object( # pylint: disable=too-many-positional-arguments

tags: A list of the values of the tags from this taxonomy to apply.

object_tag_class: Optional. Use a proxy subclass of ObjectTag for additional
validation. (e.g. only allow tagging certain types of objects.)

Raised Tag.DoesNotExist if the proposed tags are invalid for this taxonomy.
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted
(or invalid) tags.
Expand All @@ -351,20 +340,16 @@ def tag_object( # pylint: disable=too-many-positional-arguments
if not isinstance(tags, list):
raise ValueError(_("Tags must be a list, not {type}.").format(type=type(tags).__name__))

ObjectTagClass = object_tag_class
tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order

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

_check_new_tag_count(len(tags), taxonomy, object_id, taxonomy_export_id)
current_tags = _get_current_tags(
taxonomy,
tags,
object_id,
object_tag_class,
taxonomy_export_id
)

Expand All @@ -376,7 +361,7 @@ def tag_object( # pylint: disable=too-many-positional-arguments
# This tag is already applied.
object_tag = current_tags.pop(object_tag_index)
else:
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
object_tag = ObjectTag(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
updated_tags.append(object_tag)
else:
# Handle closed taxonomies:
Expand All @@ -403,18 +388,18 @@ def tag_object( # pylint: disable=too-many-positional-arguments
updated_tags.append(object_tag)
else:
# We are newly applying this tag:
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, tag=tag)
object_tag = ObjectTag(taxonomy=taxonomy, object_id=object_id, tag=tag)
updated_tags.append(object_tag)
elif taxonomy:
# Tag doesn't exist in the taxonomy and `create_invalid` is True
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
object_tag = ObjectTag(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
updated_tags.append(object_tag)
else:
# Taxonomy is None (also tag doesn't exist)
if taxonomy_export_id:
# This will always be true, since it is verified at the beginning of the function.
# This condition is placed by the type checks.
object_tag = ObjectTagClass(
object_tag = ObjectTag(
taxonomy=None,
object_id=object_id,
_value=tag_value,
Expand Down Expand Up @@ -444,7 +429,6 @@ def add_tag_to_taxonomy(
Taxonomy, an exception is raised, otherwise the newly created
Tag is returned
"""
taxonomy = taxonomy.cast()
new_tag = taxonomy.add_tag(tag, parent_tag_value, external_id)

# Resync all related ObjectTags after creating new Tag to
Expand All @@ -463,7 +447,6 @@ def update_tag_in_taxonomy(taxonomy: Taxonomy, tag: str, new_value: str):

Currently only supports updating the Tag value.
"""
taxonomy = taxonomy.cast()
updated_tag = taxonomy.update_tag(tag, new_value)

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


Expand Down
7 changes: 3 additions & 4 deletions src/openedx_tagging/import_export/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,16 @@ def _import_validations(taxonomy: Taxonomy):
"""
Validates if the taxonomy is allowed to import tags
"""
taxonomy = taxonomy.cast()
if taxonomy.allow_free_text:
raise ValueError(
_(
"Invalid taxonomy ({id}): You cannot import a free-form taxonomy."
"Invalid taxonomy ({id}): You cannot import to a free-text taxonomy."
).format(id=taxonomy.id)
)

if taxonomy.system_defined:
if taxonomy.read_only:
raise ValueError(
_(
"Invalid taxonomy ({id}): You cannot import a system-defined taxonomy."
"Invalid taxonomy ({id}): You cannot import to a read-only taxonomy."
).format(id=taxonomy.id)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
Remove the "system-defined" taxonomy machinery (Taxonomy subclasses and the
``_taxonomy_class`` casting column) in favour of a simple ``read_only`` flag.

See https://github.com/openedx/openedx-core/issues/634 for the rationale.

This migration:

* Adds the ``read_only`` boolean field to Taxonomy.
* Marks any taxonomy that used to be "system-defined" (i.e. had a
``_taxonomy_class`` set) as ``read_only=True``, so that its tags remain
immutable as they were before.
* Handles the auto-created "Languages" taxonomy (``id=-1``), which was created
by ``0012_language_taxonomy`` and is no longer supported: if it has been used
(any related object tags exist) it is converted into a regular, editable
taxonomy; otherwise it is deleted.
* Removes the now-unused ``_taxonomy_class`` column and the proxy models.
"""

from django.db import migrations, models

# The Languages taxonomy was auto-created with this fixed id by 0012_language_taxonomy.
LANGUAGE_TAXONOMY_ID = -1


def forwards(apps, schema_editor):
"""
Migrate system-defined taxonomies to the new read_only flag, and either
convert or drop the auto-created Languages taxonomy.
"""
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
ObjectTag = apps.get_model("oel_tagging", "ObjectTag")

language_taxonomy = Taxonomy.objects.filter(id=LANGUAGE_TAXONOMY_ID).first()
if language_taxonomy:
if ObjectTag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID).exists():
# It's in use, so convert it into a regular, editable taxonomy,
# keeping whatever language Tags have already been created.
language_taxonomy._taxonomy_class = None
language_taxonomy.read_only = False
language_taxonomy.save()
else:
# Unused, so remove it (and its tags) entirely.
language_taxonomy.delete()

# Any remaining taxonomy that was backed by a subclass was "system-defined",
# meaning its tags could not be modified. Preserve that by marking it read-only.
Taxonomy.objects.exclude(_taxonomy_class__isnull=True).exclude(_taxonomy_class="").update(read_only=True)


def backwards(apps, schema_editor):
"""
Nothing to undo here: the deleted Languages taxonomy and the subclass
information cannot be restored, because the subclasses no longer exist.
The ``read_only`` column is dropped by the reversal of the AddField
operation.
"""


class Migration(migrations.Migration):

dependencies = [
('oel_tagging', '0020_tag_depth_and_lineage'),
]

operations = [
migrations.DeleteModel(
name='LanguageTaxonomy',
),
migrations.DeleteModel(
name='ModelSystemDefinedTaxonomy',
),
migrations.DeleteModel(
name='SystemDefinedTaxonomy',
),
migrations.DeleteModel(
name='UserSystemDefinedTaxonomy',
),
migrations.AddField(
model_name='taxonomy',
name='read_only',
field=models.BooleanField(default=False, help_text='Indicates that the tags in this taxonomy are maintained by the system or an external integration; taxonomy admins will not be permitted to add, edit, or delete its tags.'),
),
migrations.RunPython(forwards, backwards),
migrations.RemoveField(
model_name='taxonomy',
name='_taxonomy_class',
),
]
1 change: 0 additions & 1 deletion src/openedx_tagging/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@
"""
from .base import ObjectTag, Tag, Taxonomy
from .import_export import TagImportTask, TagImportTaskState
from .system_defined import LanguageTaxonomy, ModelSystemDefinedTaxonomy, UserSystemDefinedTaxonomy
Loading