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
33 changes: 12 additions & 21 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 Down Expand Up @@ -354,9 +350,7 @@ def tag_object( # pylint: disable=too-many-positional-arguments
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)
Expand Down Expand Up @@ -444,7 +438,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 +456,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 +475,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