From f5164bb030fa30c400b5999d0097f8b342ab7836 Mon Sep 17 00:00:00 2001 From: Jillian Date: Thu, 20 Jul 2023 09:45:30 +0930 Subject: [PATCH 01/20] Allow custom Taxonomy, ObjectTag subclasses to customize tagging behavior (#62) Adds support for custom Taxonomy subclasses to be stored against a Taxonomy, to be used by the python API when instantiating and returning Taxonomies. Also adds minimal support for ObjectTag subclasses. However, these are not stored against the ObjectTag instances; they can be instantiated by the Taxonomy subclasses if and when needed. Related: * docs: updates decisions to reflect this change * feat: adds api.get_taxonomy, which returns a Taxonomy cast to its subclass, when set * refactor: adds _check_taxonomy, _check_tag, and _check_object methods to the Taxonomy class, which can be overridden by subclasses when validating ObjectTags Added to support system-defined Taxonomies: * feat: adds un-editable Taxonomy.system_defined field so that system taxonomies can store this field and ensure no one edits them. * feat: adds Taxonomy.visible_to_authors, which is needed for fully automated tagging. Cleanup changes: * fix: updates Tag model to cascade delete if the Taxonomy or parent Tag is deleted. * style: adds missing type annotations to rules and python API --- docs/decisions/0007-tagging-app.rst | 7 +- .../decisions/0009-tagging-administrators.rst | 2 +- .../0012-system-taxonomy-creation.rst | 17 +- openedx_tagging/core/tagging/api.py | 63 +++-- .../migrations/0002_auto_20230718_2026.py | 79 ++++++ openedx_tagging/core/tagging/models.py | 257 +++++++++++++++--- openedx_tagging/core/tagging/rules.py | 14 +- .../core/fixtures/tagging.yaml | 11 + .../openedx_tagging/core/tagging/test_api.py | 225 ++++++++++++--- .../core/tagging/test_models.py | 185 +++++++++---- .../core/tagging/test_rules.py | 16 +- 11 files changed, 695 insertions(+), 181 deletions(-) create mode 100644 openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py diff --git a/docs/decisions/0007-tagging-app.rst b/docs/decisions/0007-tagging-app.rst index 7dbc45b0..9993a49b 100644 --- a/docs/decisions/0007-tagging-app.rst +++ b/docs/decisions/0007-tagging-app.rst @@ -19,7 +19,7 @@ Taxonomy 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. -So in edx-platform, we will create the ``openedx.features.tagging`` app, to contain ``models.OrgTaxonomy``. OrgTaxonomy subclasses ``openedx_tagging.core.models.Taxonomy``, employing Django's `multi-table inheritance`_ feature, which allows the base Tag class to keep foreign keys to the Taxonomy, while allowing OrgTaxonomy to store foreign keys into Studio's Organization table. +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. ObjectTag ~~~~~~~~~ @@ -27,7 +27,7 @@ ObjectTag Similarly, the ``openedx_tagging`` module defined ``openedx_tagging.core.models.ObjectTag``, also self-contained to the ``openedx_tagging`` app. -But to tag content in the LMS/Studio, we create ``openedx.features.tagging.models.ContentTag``, which subclasses ``ObjectTag``, and can then reference functionality available in the platform code. +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 content object tags. Once the ``object_id`` is set, it is not editable, and so this key validation need not happen again. Rejected Alternatives --------------------- @@ -38,6 +38,3 @@ Embed in edx-platform Embedding the logic in edx-platform would provide the content tagging logic specifically required for the MVP. 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. - - -.. _multi-table inheritance: https://docs.djangoproject.com/en/3.2/topics/db/models/#multi-table-inheritance diff --git a/docs/decisions/0009-tagging-administrators.rst b/docs/decisions/0009-tagging-administrators.rst index 33b0ab52..cd528423 100644 --- a/docs/decisions/0009-tagging-administrators.rst +++ b/docs/decisions/0009-tagging-administrators.rst @@ -22,7 +22,7 @@ In the Studio context, a modified version of "course creator" access will be use Permission #1 requires no external access, so can be enforced by the ``openedx_tagging`` app. -But because permissions #2 + #3 require access to the edx-platform CMS model `CourseCreator`_, this access can only be enforced in Studio, and so will live under `cms.djangoapps.tagging` along with the ``ContentTag`` class. Tagging MVP must work for libraries v1, v2 and courses created in Studio, and so tying these permissions to Studio is reasonable for the MVP. +But because permissions #2 + #3 require access to the edx-platform CMS model `CourseCreator`_, this access can only be enforced in Studio, and so will live under ``cms.djangoapps.content_tagging`` along with the ``ContentTag`` class. Tagging MVP must work for libraries v1, v2 and courses created in Studio, and so tying these permissions to Studio is reasonable for the MVP. Per `OEP-9`_, ``openedx_tagging`` will allow applications to use the standard Django API to query permissions, for example: ``user.has_perm('openedx_tagging.edit_taxonomy', taxonomy)``, and the appropriate permissions will be applied in that application's context. diff --git a/docs/decisions/0012-system-taxonomy-creation.rst b/docs/decisions/0012-system-taxonomy-creation.rst index fe67b6e3..2ed17886 100644 --- a/docs/decisions/0012-system-taxonomy-creation.rst +++ b/docs/decisions/0012-system-taxonomy-creation.rst @@ -4,7 +4,7 @@ Context -------- -System-defined taxonomies are closed taxonomies created by the system. Some of these are totally static (e.g Language) +System-defined taxonomies are taxonomies created by the system. Some of these are totally static (e.g Language) and some depends on a core data model (e.g. Organizations). It is necessary to define how to create and validate the System-defined taxonomies and their tags. @@ -12,17 +12,15 @@ the System-defined taxonomies and their tags. Decision --------- -System-defined Taxonomy creation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +System Tag lists and validation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Each System-defined Taxonomy has its own class, which is used for tag validation (e.g. ``LanguageSystemTaxonomy``, ``OrganizationSystemTaxonomy``). -Each can overwrite ``get_tags``; to configure the valid tags, and ``validate_object_tag``; to check if a list of tags are valid. -Both functions are implemented on the ``Taxonomy`` base class, but can be overwritten to handle special cases. +Each System-defined Taxonomy will have its own ``ObjectTag`` subclass which is used for tag validation (e.g. ``LanguageObjectTag``, ``OrganizationObjectTag``). +Each subclass can overwrite ``get_tags``; to configure the valid tags, and ``is_valid``; to check if a list of tags are valid. Both functions are implemented on the ``ObjectTag`` base class, but can be overwritten to handle special cases. -We need to create an instance of each System-defined Taxonomy in a fixture. This instances will be used on different APIs. +We need to create an instance of each System-defined Taxonomy's ObjectTag in a fixture. This instances will be used on different APIs. -Later, we need to create a ``Content-side`` class that lives on ``openedx.features.tagging`` for each content and taxonomy to be used -(eg. ``CourseLanguageSystemTaxonomy``, ``CourseOrganizationSystemTaxonomy``). +Later, we need to create content-side ObjectTags that live on ``openedx.features.content_tagging`` for each content and taxonomy to be used (eg. ``CourseLanguageObjectTag``, ``CourseOrganizationObjectTag``). This new class is used to configure the automatic content tagging. You can read the `document number 0013`_ to see this configuration. Tags creation @@ -54,5 +52,4 @@ And if it's a large list of objects (e.g. Users), then copying that list into th It is better to dynamically generate the list of available Tags, and/or dynamically validate a submitted object tag than to store the options in the database. - .. _document number 0013: https://github.com/openedx/openedx-learning/blob/main/docs/decisions/0013-system-taxonomy-auto-tagging.rst diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 6b9bf2a0..5ca23c48 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -10,7 +10,7 @@ Please look at the models.py file for more information about the kinds of data are stored in this app. """ -from typing import List, Type +from typing import Iterator, List, Type, Union from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ @@ -19,17 +19,18 @@ def create_taxonomy( - name, - description=None, + name: str, + description: str = None, enabled=True, required=False, allow_multiple=False, allow_free_text=False, + taxonomy_class: Type = None, ) -> Taxonomy: """ Creates, saves, and returns a new Taxonomy with the given attributes. """ - return Taxonomy.objects.create( + taxonomy = Taxonomy( name=name, description=description, enabled=enabled, @@ -37,11 +38,27 @@ def create_taxonomy( allow_multiple=allow_multiple, allow_free_text=allow_free_text, ) + if taxonomy_class: + taxonomy.taxonomy_class = taxonomy_class + taxonomy.save() + return taxonomy.cast() + + +def get_taxonomy(id: int) -> Union[Taxonomy, None]: + """ + Returns a Taxonomy cast to the appropriate subclass which has the given ID. + """ + taxonomy = Taxonomy.objects.filter(id=id).first() + return taxonomy.cast() if taxonomy else None def get_taxonomies(enabled=True) -> QuerySet: """ 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. """ @@ -57,7 +74,7 @@ def get_tags(taxonomy: Taxonomy) -> List[Tag]: Note that if the taxonomy allows free-text tags, then the returned list will be empty. """ - return taxonomy.get_tags() + return taxonomy.cast().get_tags() def resync_object_tags(object_tags: QuerySet = None) -> int: @@ -67,7 +84,7 @@ def resync_object_tags(object_tags: QuerySet = None) -> int: By default, we iterate over all ObjectTags. Pass a filtered ObjectTags queryset to limit which tags are resynced. """ if not object_tags: - object_tags = ObjectTag.objects.all() + object_tags = ObjectTag.objects.select_related("tag", "taxonomy") num_changed = 0 for object_tag in object_tags: @@ -79,22 +96,35 @@ def resync_object_tags(object_tags: QuerySet = None) -> int: def get_object_tags( - taxonomy: Taxonomy, object_id: str, object_type: str, valid_only=True -) -> List[ObjectTag]: + object_id: str, taxonomy: Taxonomy = None, valid_only=True +) -> Iterator[ObjectTag]: """ - Returns a list of tags for a given taxonomy + content. + Generates a list of object tags for a given object. + + Pass taxonomy to limit the returned object_tags to a specific taxonomy. Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too. - Invalid tags will likely be hidden from learners. + Invalid tags will (probably) be hidden from learners. """ - tags = ObjectTag.objects.filter( - taxonomy=taxonomy, object_id=object_id, object_type=object_type - ).order_by("id") - return [tag for tag in tags if not valid_only or taxonomy.validate_object_tag(tag)] + tags = ( + ObjectTag.objects.filter( + object_id=object_id, + ) + .select_related("tag", "taxonomy") + .order_by("id") + ) + if taxonomy: + tags = tags.filter(taxonomy=taxonomy) + + for object_tag in tags: + if not valid_only or object_tag.is_valid(): + yield object_tag def tag_object( - taxonomy: Taxonomy, tags: List, object_id: str, object_type: str + taxonomy: Taxonomy, + tags: List, + object_id: str, ) -> List[ObjectTag]: """ Replaces the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags. @@ -105,5 +135,4 @@ def tag_object( Raised ValueError if the proposed tags are invalid for this taxonomy. Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. """ - - return taxonomy.tag_object(tags, object_id, object_type) + return taxonomy.cast().tag_object(tags, object_id) diff --git a/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py b/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py new file mode 100644 index 00000000..d0d14c93 --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.19 on 2023-07-18 05:54 + +import django.db.models.deletion +from django.db import migrations, models + +import openedx_learning.lib.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("oel_tagging", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="taxonomy", + name="system_defined", + field=models.BooleanField( + default=False, + editable=False, + help_text="Indicates that tags and metadata for this taxonomy are maintained by the system; taxonomy admins will not be permitted to modify them.", + ), + ), + migrations.AlterField( + model_name="tag", + name="parent", + field=models.ForeignKey( + default=None, + help_text="Tag that lives one level up from the current tag, forming a hierarchy.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="oel_tagging.tag", + ), + ), + migrations.AlterField( + model_name="tag", + name="taxonomy", + field=models.ForeignKey( + default=None, + help_text="Namespace and rules for using a given set of tags.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="oel_tagging.taxonomy", + ), + ), + migrations.AddField( + model_name="taxonomy", + name="visible_to_authors", + field=models.BooleanField( + default=True, + editable=False, + help_text="Indicates whether this taxonomy should be visible to object authors.", + ), + ), + migrations.RemoveField( + model_name="objecttag", + name="object_type", + ), + migrations.AddField( + model_name="taxonomy", + name="_taxonomy_class", + field=models.CharField( + help_text="Taxonomy subclass used to instantiate this instance; must be a fully-qualified module and class name. If the module/class cannot be imported, an error is logged and the base Taxonomy class is used instead.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="objecttag", + name="object_id", + field=openedx_learning.lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_unicode_ci", "sqlite": "NOCASE"}, + editable=False, + help_text="Identifier for the object being tagged", + max_length=255, + ), + ), + ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index c90f8a34..3c8222bc 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -1,11 +1,15 @@ """ Tagging app data models """ -from typing import List, Type +import logging +from typing import List, Type, Union from django.db import models +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field +log = logging.getLogger(__name__) + # Maximum depth allowed for a hierarchical taxonomy's tree of tags. TAXONOMY_MAX_DEPTH = 3 @@ -29,14 +33,14 @@ class Tag(models.Model): "Taxonomy", null=True, default=None, - on_delete=models.SET_NULL, + on_delete=models.CASCADE, help_text=_("Namespace and rules for using a given set of tags."), ) parent = models.ForeignKey( "self", null=True, default=None, - on_delete=models.SET_NULL, + on_delete=models.CASCADE, related_name="children", help_text=_( "Tag that lives one level up from the current tag, forming a hierarchy." @@ -73,7 +77,7 @@ def __str__(self): """ User-facing string representation of a Tag. """ - return f"Tag ({self.id}) {self.value}" + return f"<{self.__class__.__name__}> ({self.id}) {self.value}" def get_lineage(self) -> Lineage: """ @@ -95,7 +99,7 @@ def get_lineage(self) -> Lineage: class Taxonomy(models.Model): """ - Represents a namespace and rules for a group of tags which can be applied to a particular Open edX object. + Represents a namespace and rules for a group of tags. """ id = models.BigAutoField(primary_key=True) @@ -136,20 +140,109 @@ class Taxonomy(models.Model): "Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values." ), ) + system_defined = models.BooleanField( + default=False, + editable=False, + help_text=_( + "Indicates that tags and metadata for this taxonomy are maintained by the system;" + " taxonomy admins will not be permitted to modify them.", + ), + ) + visible_to_authors = models.BooleanField( + default=True, + editable=False, + help_text=_( + "Indicates whether this taxonomy should be visible to object authors." + ), + ) + _taxonomy_class = models.CharField( + null=True, + max_length=255, + help_text=_( + "Taxonomy subclass used to instantiate this instance; must be a fully-qualified module and class name." + " If the module/class cannot be imported, an error is logged and the base Taxonomy class is used instead." + ), + ) class Meta: verbose_name_plural = "Taxonomies" + def __repr__(self): + """ + Developer-facing representation of a Taxonomy. + """ + return str(self) + + def __str__(self): + """ + User-facing string representation of a Taxonomy. + """ + return f"<{self.__class__.__name__}> ({self.id}) {self.name}" + @property - def system_defined(self) -> bool: + def taxonomy_class(self) -> Type: + """ + Returns the Taxonomy subclass associated with this instance, or None if none supplied. + + May raise ImportError if a custom taxonomy_class cannot be imported. + """ + if self._taxonomy_class: + return import_string(self._taxonomy_class) + return None + + @taxonomy_class.setter + def taxonomy_class(self, taxonomy_class: Union[Type, None]): + """ + Assigns the given taxonomy_class's module path.class to the field. + + Must be a subclass of Taxonomy, or raises a ValueError. + """ + if taxonomy_class: + if not issubclass(taxonomy_class, Taxonomy): + raise ValueError( + f"Unable to assign taxonomy_class for {self}: {taxonomy_class} must be a subclass of Taxonomy" + ) + + # ref: https://stackoverflow.com/a/2020083 + self._taxonomy_class = ".".join( + [taxonomy_class.__module__, taxonomy_class.__qualname__] + ) + else: + self._taxonomy_class = None + + def cast(self): """ - Base taxonomies are user-defined, not system-defined. + Returns the current Taxonomy instance cast into its taxonomy_class. + + If no taxonomy_class is set, or if we're unable to import it, then just returns self. + """ + try: + TaxonomyClass = self.taxonomy_class + if TaxonomyClass and not isinstance(self, TaxonomyClass): + return TaxonomyClass().copy(self) + except ImportError: + # Log error and continue + log.exception( + f"Unable to import taxonomy_class for {self}: {self._taxonomy_class}" + ) - System-defined taxonomies cannot be edited by ordinary users. + return self - Subclasses should override this property as required. + def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": """ - return False + Copy the fields from the given Taxonomy into the current instance. + """ + self.id = taxonomy.id + self.name = taxonomy.name + self.description = taxonomy.description + self.enabled = taxonomy.enabled + self.required = taxonomy.required + self.allow_multiple = taxonomy.allow_multiple + self.allow_free_text = taxonomy.allow_free_text + self.system_defined = taxonomy.system_defined + self.visible_to_authors = taxonomy.visible_to_authors + self._taxonomy_class = taxonomy._taxonomy_class + return self def get_tags(self) -> List[Tag]: """ @@ -195,38 +288,72 @@ def validate_object_tag( """ Returns True if the given object tag is valid for the current Taxonomy. - Subclasses can override this method to perform their own validation checks, e.g. against dynamically generated - tag lists. + Subclasses should override the internal _validate* methods to perform their own validation checks, e.g. against + dynamically generated tag lists. If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. If `check_tag` is False, then we skip validating the object tag's tag reference. If `check_object` is False, then we skip validating the object ID/type. """ - # Must be linked to this taxonomy - if check_taxonomy and ( - not object_tag.taxonomy_id or object_tag.taxonomy_id != self.id - ): + if check_taxonomy and not self._check_taxonomy(object_tag): return False - # Must be linked to a Tag unless its a free-text taxonomy - if check_tag and (not self.allow_free_text and not object_tag.tag_id): + if check_tag and not self._check_tag(object_tag): return False - # Must have a valid object id/type: - if check_object and (not object_tag.object_id or not object_tag.object_type): + if check_object and not self._check_object(object_tag): return False return True + def _check_taxonomy( + self, + object_tag: "ObjectTag", + ) -> bool: + """ + Returns True if the given object tag is valid for the current Taxonomy. + + Subclasses can override this method to perform their own taxonomy validation checks. + """ + # Must be linked to this taxonomy + return object_tag.taxonomy_id and object_tag.taxonomy_id == self.id + + def _check_tag( + self, + object_tag: "ObjectTag", + ) -> bool: + """ + Returns True if the given object tag's value is valid for the current Taxonomy. + + Subclasses can override this method to perform their own taxonomy validation checks. + """ + # Open taxonomies only need a value. + if self.allow_free_text: + return bool(object_tag.value) + + # Closed taxonomies need an associated tag in this taxonomy + return object_tag.tag_id and object_tag.tag.taxonomy_id == self.id + + def _check_object( + self, + object_tag: "ObjectTag", + ) -> bool: + """ + Returns True if the given object tag's object is valid for the current Taxonomy. + + Subclasses can override this method to perform their own taxonomy validation checks. + """ + return bool(object_tag.object_id) + def tag_object( - self, tags: List, object_id: str, object_type: str + self, + tags: List, + object_id: str, ) -> List["ObjectTag"]: """ Replaces the existing ObjectTag entries for the current taxonomy + object_id with the given list of tags. - If self.allows_free_text, then the list should be a list of tag values. Otherwise, it should be a list of existing Tag IDs. - Raised ValueError if the proposed tags are invalid for this taxonomy. Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. """ @@ -242,7 +369,8 @@ def tag_object( current_tags = { tag.tag_ref: tag for tag in ObjectTag.objects.filter( - taxonomy=self, object_id=object_id, object_type=object_type + taxonomy=self, + object_id=object_id, ) } updated_tags = [] @@ -253,7 +381,6 @@ def tag_object( object_tag = ObjectTag( taxonomy=self, object_id=object_id, - object_type=object_type, ) try: @@ -304,12 +431,9 @@ class ObjectTag(models.Model): id = models.BigAutoField(primary_key=True) object_id = case_insensitive_char_field( max_length=255, + editable=False, help_text=_("Identifier for the object being tagged"), ) - object_type = case_insensitive_char_field( - max_length=255, - help_text=_("Type of object being tagged"), - ) taxonomy = models.ForeignKey( Taxonomy, null=True, @@ -351,6 +475,18 @@ class Meta: models.Index(fields=["taxonomy", "_value"]), ] + def __repr__(self): + """ + Developer-facing representation of an ObjectTag. + """ + return str(self) + + def __str__(self): + """ + User-facing string representation of an ObjectTag. + """ + return f"<{self.__class__.__name__}> {self.object_id}: {self.name}={self.value}" + @property def name(self) -> str: """ @@ -395,7 +531,6 @@ def tag_ref(self) -> str: """ return self.tag.id if self.tag_id else self._value - @property def is_valid(self) -> bool: """ Returns True if this ObjectTag represents a valid taxonomy tag. @@ -426,25 +561,42 @@ def resync(self) -> bool: """ changed = False - # Locate a taxonomy matching _name + # Locate an enabled taxonomy matching _name, and maybe a tag matching _value if not self.taxonomy_id: - for taxonomy in Taxonomy.objects.filter(name=self.name, enabled=True): - # Make sure this taxonomy will accept object tags like this. - self.taxonomy = taxonomy - if taxonomy.validate_object_tag(self, check_tag=False): - changed = True - break - # If not, try the next one - else: - self.taxonomy = None + # Use the linked tag's taxonomy if there is one. + if self.tag_id: + self.taxonomy_id = self.tag.taxonomy_id + changed = True + else: + for taxonomy in Taxonomy.objects.filter( + name=self.name, enabled=True + ).order_by("allow_free_text", "id"): + # Cast to the subclass to preserve custom validation + taxonomy = taxonomy.cast() + + # Closed taxonomies require a tag matching _value, + # and we'd rather match a closed taxonomy than an open one. + # So see if there's a matching tag available in this taxonomy. + tag = taxonomy.tag_set.filter(value=self.value).first() + + # Make sure this taxonomy will accept object tags like this. + self.taxonomy = taxonomy + self.tag = tag + if taxonomy.validate_object_tag(self): + changed = True + break + # If not, undo those changes and try the next one + else: + self.taxonomy = None + self.tag = None # Sync the stored _name with the taxonomy.name - elif self._name != self.taxonomy.name: + if self.taxonomy_id and self._name != self.taxonomy.name: self.name = self.taxonomy.name changed = True - # Locate a tag matching _value - if self.taxonomy and not self.tag_id and not self.taxonomy.allow_free_text: + # Closed taxonomies require a tag matching _value + if self.taxonomy and not self.taxonomy.allow_free_text and not self.tag_id: tag = self.taxonomy.tag_set.filter(value=self.value).first() if tag: self.tag = tag @@ -456,3 +608,22 @@ def resync(self) -> bool: changed = True return changed + + @classmethod + def cast(cls, object_tag: "ObjectTag") -> "ObjectTag": + """ + Returns a cls instance with the same properties as the given ObjectTag. + """ + return cls().copy(object_tag) + + def copy(self, object_tag: "ObjectTag") -> "ObjectTag": + """ + Copy the fields from the given ObjectTag into the current instance. + """ + self.id = object_tag.id + self.tag = object_tag.tag + self.taxonomy = object_tag.taxonomy + self.object_id = object_tag.object_id + self._value = object_tag._value + self._name = object_tag._name + return self diff --git a/openedx_tagging/core/tagging/rules.py b/openedx_tagging/core/tagging/rules.py index 7ef984b8..72b9fa6b 100644 --- a/openedx_tagging/core/tagging/rules.py +++ b/openedx_tagging/core/tagging/rules.py @@ -1,6 +1,12 @@ """Django rules-based permissions for tagging""" import rules +from django.contrib.auth import get_user_model + +from .models import ObjectTag, Tag, Taxonomy + +User = get_user_model() + # Global staff are taxonomy admins. # (Superusers can already do anything) @@ -8,7 +14,7 @@ @rules.predicate -def can_view_taxonomy(user, taxonomy=None): +def can_view_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool: """ Anyone can view an enabled taxonomy, but only taxonomy admins can view a disabled taxonomy. @@ -17,7 +23,7 @@ def can_view_taxonomy(user, taxonomy=None): @rules.predicate -def can_change_taxonomy(user, taxonomy=None): +def can_change_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool: """ Even taxonomy admins cannot change system taxonomies. """ @@ -27,7 +33,7 @@ def can_change_taxonomy(user, taxonomy=None): @rules.predicate -def can_change_taxonomy_tag(user, tag=None): +def can_change_taxonomy_tag(user: User, tag: Tag = None) -> bool: """ Even taxonomy admins cannot add tags to system taxonomies (their tags are system-defined), or free-text taxonomies (these don't have predefined tags). @@ -44,7 +50,7 @@ def can_change_taxonomy_tag(user, tag=None): @rules.predicate -def can_change_object_tag(user, object_tag=None): +def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool: """ Taxonomy admins can create or modify object tags on enabled taxonomies. """ diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml index 50b9459b..a56ece81 100644 --- a/tests/openedx_tagging/core/fixtures/tagging.yaml +++ b/tests/openedx_tagging/core/fixtures/tagging.yaml @@ -154,3 +154,14 @@ required: false allow_multiple: false allow_free_text: false + system_defined: false +- model: oel_tagging.taxonomy + pk: 2 + fields: + name: System Languages + description: Allows tags for any language configured for use on the instance. + enabled: true + required: false + allow_multiple: false + allow_free_text: false + system_defined: true diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index 7a418e68..798b4826 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -1,7 +1,5 @@ """ Test the tagging APIs """ -from unittest.mock import patch - from django.test.testcases import TestCase import openedx_tagging.core.tagging.api as tagging_api @@ -27,18 +25,41 @@ def test_create_taxonomy(self): taxonomy = tagging_api.create_taxonomy(**params) for param, value in params.items(): assert getattr(taxonomy, param) == value + assert not taxonomy.system_defined + assert taxonomy.visible_to_authors + + def test_bad_taxonomy_class(self): + with self.assertRaises(ValueError) as exc: + tagging_api.create_taxonomy( + name="Bad class", + taxonomy_class=str, + ) + assert " must be a subclass of Taxonomy" in str(exc.exception) + + def test_get_taxonomy(self): + tax1 = tagging_api.get_taxonomy(1) + assert tax1 == self.taxonomy + no_tax = tagging_api.get_taxonomy(10) + assert no_tax is None def test_get_taxonomies(self): tax1 = tagging_api.create_taxonomy("Enabled") tax2 = tagging_api.create_taxonomy("Disabled", enabled=False) - enabled = tagging_api.get_taxonomies() - assert list(enabled) == [tax1, self.taxonomy] + with self.assertNumQueries(1): + enabled = list(tagging_api.get_taxonomies()) + assert enabled == [tax1, self.taxonomy, self.system_taxonomy] + assert str(enabled[0]) == f" ({tax1.id}) Enabled" + assert str(enabled[1]) == " (1) Life on Earth" + assert str(enabled[2]) == " (2) System Languages" - disabled = tagging_api.get_taxonomies(enabled=False) - assert list(disabled) == [tax2] + with self.assertNumQueries(1): + disabled = list(tagging_api.get_taxonomies(enabled=False)) + assert disabled == [tax2] + assert str(disabled[0]) == f" ({tax2.id}) Disabled" - both = tagging_api.get_taxonomies(enabled=None) - assert list(both) == [tax2, tax1, self.taxonomy] + with self.assertNumQueries(1): + both = list(tagging_api.get_taxonomies(enabled=None)) + assert both == [tax2, tax1, self.taxonomy, self.system_taxonomy] def test_get_tags(self): self.setup_tag_depths() @@ -59,23 +80,21 @@ def check_object_tag(self, object_tag, taxonomy, tag, name, value): assert object_tag.value == value def test_resync_object_tags(self): - missing_links = ObjectTag(object_id="abc", object_type="alpha") - missing_links.name = self.taxonomy.name - missing_links.value = self.mammalia.value - missing_links.save() - changed_links = ObjectTag( + missing_links = ObjectTag.objects.create( + object_id="abc", + _name=self.taxonomy.name, + _value=self.mammalia.value, + ) + changed_links = ObjectTag.objects.create( object_id="def", - object_type="alpha", taxonomy=self.taxonomy, tag=self.mammalia, ) changed_links.name = "Life" changed_links.value = "Animals" changed_links.save() - - no_changes = ObjectTag( + no_changes = ObjectTag.objects.create( object_id="ghi", - object_type="beta", taxonomy=self.taxonomy, tag=self.mammalia, ) @@ -94,14 +113,44 @@ def test_resync_object_tags(self): changed = tagging_api.resync_object_tags() assert changed == 0 + # Resync will use the tag's taxonomy if possible + changed_links.taxonomy = None + changed_links.save() + changed = tagging_api.resync_object_tags() + assert changed == 1 + for object_tag in (missing_links, changed_links, no_changes): + self.check_object_tag( + object_tag, self.taxonomy, self.mammalia, "Life on Earth", "Mammalia" + ) + + # Resync will use the taxonomy's tags if possible + changed_links.tag = None + changed_links.value = "Xenomorph" + changed_links.save() + changed = tagging_api.resync_object_tags() + assert changed == 0 + changed_links.value = "Mammalia" + changed_links.save() + # ObjectTag value preserved even if linked tag is deleted self.mammalia.delete() for object_tag in (missing_links, changed_links, no_changes): self.check_object_tag( object_tag, self.taxonomy, None, "Life on Earth", "Mammalia" ) + # Recreating the tag to test resyncing works + new_mammalia = Tag.objects.create( + value="Mammalia", + taxonomy=self.taxonomy, + ) + changed = tagging_api.resync_object_tags() + assert changed == 3 + for object_tag in (missing_links, changed_links, no_changes): + self.check_object_tag( + object_tag, self.taxonomy, new_mammalia, "Life on Earth", "Mammalia" + ) - # ObjectTag name preserved even if linked taxonomy is deleted + # ObjectTag name preserved even if linked taxonomy and its tags are deleted self.taxonomy.delete() for object_tag in (missing_links, changed_links, no_changes): self.check_object_tag(object_tag, None, None, "Life on Earth", "Mammalia") @@ -111,21 +160,21 @@ def test_resync_object_tags(self): assert changed == 0 # Recreate the taxonomy and resync some tags - first_taxonomy = tagging_api.create_taxonomy("Life on Earth") + first_taxonomy = tagging_api.create_taxonomy( + "Life on Earth", allow_free_text=True + ) second_taxonomy = tagging_api.create_taxonomy("Life on Earth") new_tag = Tag.objects.create( value="Mammalia", taxonomy=second_taxonomy, ) - with patch( - "openedx_tagging.core.tagging.models.Taxonomy.validate_object_tag", - side_effect=[False, True, False, True], - ): - changed = tagging_api.resync_object_tags( - ObjectTag.objects.filter(object_type="alpha") - ) - assert changed == 2 + # Ensure the resync prefers the closed taxonomy with the matching tag + changed = tagging_api.resync_object_tags( + ObjectTag.objects.filter(object_id__in=["abc", "def"]) + ) + assert changed == 2 + for object_tag in (missing_links, changed_links): self.check_object_tag( object_tag, second_taxonomy, new_tag, "Life on Earth", "Mammalia" @@ -134,17 +183,20 @@ def test_resync_object_tags(self): # Ensure the omitted tag was not updated self.check_object_tag(no_changes, None, None, "Life on Earth", "Mammalia") - # Update that one too (without the patching) + # Update that one too, to demonstrate the free-text tags are ok + no_changes.value = "Anamelia" + no_changes.save() changed = tagging_api.resync_object_tags( - ObjectTag.objects.filter(object_type="beta") + ObjectTag.objects.filter(id=no_changes.id) ) assert changed == 1 self.check_object_tag( - no_changes, first_taxonomy, None, "Life on Earth", "Mammalia" + no_changes, first_taxonomy, None, "Life on Earth", "Anamelia" ) def test_tag_object(self): self.taxonomy.allow_multiple = True + self.taxonomy.save() test_tags = [ [ self.archaea.id, @@ -167,15 +219,15 @@ def test_tag_object(self): self.taxonomy, tag_list, "biology101", - "course", ) # Ensure the expected number of tags exist in the database assert ( - tagging_api.get_object_tags( - taxonomy=self.taxonomy, - object_id="biology101", - object_type="course", + list( + tagging_api.get_object_tags( + taxonomy=self.taxonomy, + object_id="biology101", + ) ) == object_tags ) @@ -183,8 +235,107 @@ def test_tag_object(self): assert len(object_tags) == len(tag_list) for index, object_tag in enumerate(object_tags): assert object_tag.tag_id == tag_list[index] - assert object_tag.is_valid + assert object_tag.is_valid() assert object_tag.taxonomy == self.taxonomy assert object_tag.name == self.taxonomy.name assert object_tag.object_id == "biology101" - assert object_tag.object_type == "course" + + def test_tag_object_free_text(self): + self.taxonomy.allow_free_text = True + self.taxonomy.save() + object_tags = tagging_api.tag_object( + self.taxonomy, + ["Eukaryota Xenomorph"], + "biology101", + ) + assert len(object_tags) == 1 + object_tag = object_tags[0] + assert object_tag.is_valid() + assert object_tag.taxonomy == self.taxonomy + assert object_tag.name == self.taxonomy.name + assert object_tag.tag_ref == "Eukaryota Xenomorph" + assert object_tag.get_lineage() == ["Eukaryota Xenomorph"] + assert object_tag.object_id == "biology101" + + def test_tag_object_no_multiple(self): + with self.assertRaises(ValueError) as exc: + tagging_api.tag_object( + self.taxonomy, + ["A", "B"], + "biology101", + ) + assert "only allows one tag per object" in str(exc.exception) + + def test_tag_object_required(self): + self.taxonomy.required = True + self.taxonomy.save() + with self.assertRaises(ValueError) as exc: + tagging_api.tag_object( + self.taxonomy, + [], + "biology101", + ) + assert "requires at least one tag per object" in str(exc.exception) + + def test_tag_object_invalid_tag(self): + with self.assertRaises(ValueError) as exc: + tagging_api.tag_object( + self.taxonomy, + ["Eukaryota Xenomorph"], + "biology101", + ) + assert "Invalid object tag for taxonomy (1): Eukaryota Xenomorph" in str( + exc.exception + ) + + def test_get_object_tags(self): + # Alpha tag has no taxonomy + alpha = ObjectTag(object_id="abc") + alpha.name = self.taxonomy.name + alpha.value = self.mammalia.value + alpha.save() + # Beta tag has a closed taxonomy + beta = ObjectTag.objects.create( + object_id="abc", + taxonomy=self.taxonomy, + ) + + # Fetch all the tags for a given object ID + assert list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=False, + ) + ) == [ + alpha, + beta, + ] + + # No valid tags for this object yet.. + assert not list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=True, + ) + ) + beta.tag = self.mammalia + beta.save() + assert list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=True, + ) + ) == [ + beta, + ] + + # Fetch all the tags for a given object ID + taxonomy + assert list( + tagging_api.get_object_tags( + object_id="abc", + taxonomy=self.taxonomy, + valid_only=False, + ) + ) == [ + beta, + ] diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index d74c041d..65af41ea 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -23,6 +23,7 @@ class TestTagTaxonomyMixin: def setUp(self): super().setUp() self.taxonomy = Taxonomy.objects.get(name="Life on Earth") + self.system_taxonomy = Taxonomy.objects.get(name="System Languages") self.archaea = get_tag("Archaea") self.archaebacteria = get_tag("Archaebacteria") self.bacteria = get_tag("Bacteria") @@ -77,6 +78,39 @@ def setup_tag_depths(self): tag.depth = 2 +class TestTaxonomySubclassA(Taxonomy): + """ + Model A for testing the taxonomy subclass casting. + """ + + class Meta: + managed = False + proxy = True + app_label = "oel_tagging" + + +class TestTaxonomySubclassB(TestTaxonomySubclassA): + """ + Model B for testing the taxonomy subclass casting. + """ + + class Meta: + managed = False + proxy = True + app_label = "oel_tagging" + + +class TestObjectTagSubclass(ObjectTag): + """ + Model for testing the ObjectTag copy. + """ + + class Meta: + managed = False + proxy = True + app_label = "oel_tagging" + + @ddt.ddt class TestModelTagTaxonomy(TestTagTaxonomyMixin, TestCase): """ @@ -85,10 +119,57 @@ class TestModelTagTaxonomy(TestTagTaxonomyMixin, TestCase): def test_system_defined(self): assert not self.taxonomy.system_defined + assert self.system_taxonomy.system_defined def test_representations(self): - assert str(self.bacteria) == "Tag (1) Bacteria" - assert repr(self.bacteria) == "Tag (1) Bacteria" + assert ( + str(self.taxonomy) == repr(self.taxonomy) == " (1) Life on Earth" + ) + assert ( + str(self.system_taxonomy) + == repr(self.system_taxonomy) + == " (2) System Languages" + ) + assert str(self.bacteria) == repr(self.bacteria) == " (1) Bacteria" + + def test_taxonomy_cast(self): + for subclass in ( + TestTaxonomySubclassA, + # Ensure that casting to a sub-subclass works as expected + TestTaxonomySubclassB, + # and that we can un-set the subclass + None, + ): + self.taxonomy.taxonomy_class = subclass + cast_taxonomy = self.taxonomy.cast() + if subclass: + expected_class = subclass.__name__ + else: + expected_class = "Taxonomy" + assert self.taxonomy == cast_taxonomy + assert ( + str(cast_taxonomy) + == repr(cast_taxonomy) + == f"<{expected_class}> (1) Life on Earth" + ) + + def test_taxonomy_cast_import_error(self): + taxonomy = Taxonomy.objects.create( + name="Invalid cast", _taxonomy_class="not.a.class" + ) + # Error is logged, but ignored. + cast_taxonomy = taxonomy.cast() + assert cast_taxonomy == taxonomy + assert ( + str(cast_taxonomy) + == repr(cast_taxonomy) + == f" ({taxonomy.id}) Invalid cast" + ) + + def test_taxonomy_cast_bad_value(self): + with self.assertRaises(ValueError) as exc: + self.taxonomy.taxonomy_class = str + assert " must be a subclass of Taxonomy" in str(exc.exception) @ddt.data( # Root tags just return their own value @@ -136,32 +217,46 @@ class TestModelObjectTag(TestTagTaxonomyMixin, TestCase): def setUp(self): super().setUp() self.tag = self.bacteria + self.object_tag = ObjectTag.objects.create( + object_id="object:id:1", + taxonomy=self.taxonomy, + tag=self.tag, + ) + + def test_representations(self): + assert ( + str(self.object_tag) + == repr(self.object_tag) + == " object:id:1: Life on Earth=Bacteria" + ) + + def test_cast(self): + copy_tag = TestObjectTagSubclass.cast(self.object_tag) + assert ( + str(copy_tag) + == repr(copy_tag) + == " object:id:1: Life on Earth=Bacteria" + ) def test_object_tag_name(self): # ObjectTag's name defaults to its taxonomy's name - object_tag = ObjectTag.objects.create( - object_id="object:id", - object_type="any_old_object", - taxonomy=self.taxonomy, - ) - assert object_tag.name == self.taxonomy.name + assert self.object_tag.name == self.taxonomy.name # Even if we overwrite the name, it still uses the taxonomy's name - object_tag.name = "Another tag" - assert object_tag.name == self.taxonomy.name - object_tag.save() - assert object_tag.name == self.taxonomy.name + self.object_tag.name = "Another tag" + assert self.object_tag.name == self.taxonomy.name + self.object_tag.save() + assert self.object_tag.name == self.taxonomy.name # But if the taxonomy is deleted, then the object_tag's name reverts to our cached name self.taxonomy.delete() - object_tag.refresh_from_db() - assert object_tag.name == "Another tag" + self.object_tag.refresh_from_db() + assert self.object_tag.name == "Another tag" def test_object_tag_value(self): # ObjectTag's value defaults to its tag's value object_tag = ObjectTag.objects.create( object_id="object:id", - object_type="any_old_object", taxonomy=self.taxonomy, tag=self.tag, ) @@ -182,7 +277,6 @@ def test_object_tag_lineage(self): # ObjectTag's value defaults to its tag's lineage object_tag = ObjectTag.objects.create( object_id="object:id", - object_type="any_old_object", taxonomy=self.taxonomy, tag=self.tag, ) @@ -200,41 +294,34 @@ def test_object_tag_lineage(self): assert object_tag.get_lineage() == ["Another tag"] def test_object_tag_is_valid(self): - object_tag = ObjectTag( - object_id="object:id", - object_type="any_old_object", + open_taxonomy = Taxonomy.objects.create( + name="Freetext Life", + allow_free_text=True, ) - assert not object_tag.is_valid - - object_tag.taxonomy = self.taxonomy - assert not object_tag.is_valid - object_tag.tag = self.tag - assert object_tag.is_valid - - # or, we can have no tag, and a free-text taxonomy - object_tag.tag = None - self.taxonomy.allow_free_text = True - assert object_tag.is_valid - - def test_validate_object_tag_invalid(self): - taxonomy = Taxonomy.objects.create( - name="Another taxonomy", - ) object_tag = ObjectTag( taxonomy=self.taxonomy, ) - assert not taxonomy.validate_object_tag(object_tag) - - object_tag.taxonomy = taxonomy - assert not taxonomy.validate_object_tag(object_tag) - - taxonomy.allow_free_text = True - assert not taxonomy.validate_object_tag(object_tag) - + # ObjectTag will only be valid for its taxonomy + assert not open_taxonomy.validate_object_tag(object_tag) + + # ObjectTags in a free-text taxonomy are valid with a value + assert not object_tag.is_valid() + object_tag.value = "Any text we want" + object_tag.taxonomy = open_taxonomy + assert not object_tag.is_valid() object_tag.object_id = "object:id" - object_tag.object_type = "object:type" - assert taxonomy.validate_object_tag(object_tag) + assert object_tag.is_valid() + + # ObjectTags in a closed taxonomy require a tag in that taxonomy + object_tag.taxonomy = self.taxonomy + object_tag.tag = Tag.objects.create( + taxonomy=self.system_taxonomy, + value="PT", + ) + assert not object_tag.is_valid() + object_tag.tag = self.tag + assert object_tag.is_valid() def test_tag_object(self): self.taxonomy.allow_multiple = True @@ -260,14 +347,12 @@ def test_tag_object(self): object_tags = self.taxonomy.tag_object( tag_list, "biology101", - "course", ) # Ensure the expected number of tags exist in the database assert ObjectTag.objects.filter( taxonomy=self.taxonomy, object_id="biology101", - object_type="course", ).count() == len(tag_list) # And the expected number of tags were returned assert len(object_tags) == len(tag_list) @@ -277,14 +362,12 @@ def test_tag_object(self): assert object_tag.taxonomy == self.taxonomy assert object_tag.name == self.taxonomy.name assert object_tag.object_id == "biology101" - assert object_tag.object_type == "course" def test_tag_object_free_text(self): self.taxonomy.allow_free_text = True object_tags = self.taxonomy.tag_object( ["Eukaryota Xenomorph"], "biology101", - "course", ) assert len(object_tags) == 1 object_tag = object_tags[0] @@ -294,14 +377,12 @@ def test_tag_object_free_text(self): assert object_tag.tag_ref == "Eukaryota Xenomorph" assert object_tag.get_lineage() == ["Eukaryota Xenomorph"] assert object_tag.object_id == "biology101" - assert object_tag.object_type == "course" def test_tag_object_no_multiple(self): with self.assertRaises(ValueError) as exc: self.taxonomy.tag_object( ["A", "B"], "biology101", - "course", ) assert "only allows one tag per object" in str(exc.exception) @@ -311,7 +392,6 @@ def test_tag_object_required(self): self.taxonomy.tag_object( [], "biology101", - "course", ) assert "requires at least one tag per object" in str(exc.exception) @@ -320,6 +400,5 @@ def test_tag_object_invalid_tag(self): self.taxonomy.tag_object( ["Eukaryota Xenomorph"], "biology101", - "course", ) assert "Invalid object tag for taxonomy" in str(exc.exception) diff --git a/tests/openedx_tagging/core/tagging/test_rules.py b/tests/openedx_tagging/core/tagging/test_rules.py index 2c2b4e74..577c594f 100644 --- a/tests/openedx_tagging/core/tagging/test_rules.py +++ b/tests/openedx_tagging/core/tagging/test_rules.py @@ -3,9 +3,8 @@ import ddt from django.contrib.auth import get_user_model from django.test.testcases import TestCase -from mock import Mock -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from openedx_tagging.core.tagging.models import ObjectTag, Tag from .test_models import TestTagTaxonomyMixin @@ -34,12 +33,10 @@ def setUp(self): username="learner", email="learner@example.com", ) - - self.object_tag = ObjectTag( + self.object_tag = ObjectTag.objects.create( taxonomy=self.taxonomy, tag=self.bacteria, ) - self.object_tag.resync() self.object_tag.save() # Taxonomy @@ -64,12 +61,9 @@ def test_add_change_taxonomy(self, perm): ) def test_system_taxonomy(self, perm): """Taxonomy administrators cannot edit system taxonomies""" - # TODO: use SystemTaxonomy when available - system_taxonomy = Mock(spec=Taxonomy) - system_taxonomy.system_defined.return_value = True - assert self.superuser.has_perm(perm, system_taxonomy) - assert not self.staff.has_perm(perm, system_taxonomy) - assert not self.learner.has_perm(perm, system_taxonomy) + assert self.superuser.has_perm(perm, self.system_taxonomy) + assert not self.staff.has_perm(perm, self.system_taxonomy) + assert not self.learner.has_perm(perm, self.system_taxonomy) @ddt.data( True, From 09a8fec0d8b9bc4536a0cc57ae4d149d08e7cfb6 Mon Sep 17 00:00:00 2001 From: Jillian Date: Thu, 20 Jul 2023 16:26:43 +0930 Subject: [PATCH 02/20] fix: removes COMPRESSED row format from openedx_learning core (#66) AWS Aurora does not support COMPRESSED in its MySQL implementation: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Migrating.RDSMySQL.Import.html --- .../core/contents/migrations/0001_initial.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openedx_learning/core/contents/migrations/0001_initial.py b/openedx_learning/core/contents/migrations/0001_initial.py index 76873038..507056fc 100644 --- a/openedx_learning/core/contents/migrations/0001_initial.py +++ b/openedx_learning/core/contents/migrations/0001_initial.py @@ -7,20 +7,6 @@ import openedx_learning.lib.validators -def use_compressed_table_format(apps, schema_editor): - """ - Use the COMPRESSED row format for TextContent if we're using MySQL. - - This table will hold a lot of OLX, which compresses very well using MySQL's - built-in zlib compression. This is especially important because we're - keeping so much version history. - """ - if schema_editor.connection.vendor == 'mysql': - table_name = apps.get_model("oel_contents", "TextContent")._meta.db_table - sql = f"ALTER TABLE {table_name} ROW_FORMAT=COMPRESSED;" - schema_editor.execute(sql) - - class Migration(migrations.Migration): initial = True @@ -55,7 +41,6 @@ class Migration(migrations.Migration): ], ), # Call out to custom code here to change row format for TextContent - migrations.RunPython(use_compressed_table_format, reverse_code=migrations.RunPython.noop, atomic=False), migrations.AddIndex( model_name='rawcontent', index=models.Index(fields=['learning_package', 'mime_type'], name='oel_content_idx_lp_mime_type'), From ac21e1c66dc49b7d31fa6a9baad7020021346f07 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 3 Jul 2023 15:39:15 -0500 Subject: [PATCH 03/20] feat: System-defined taxonomies --- .../tagging/management/commands/__init__.py | 0 .../commands/build_language_fixture.py | 60 + .../tagging/migrations/0002_systemtaxonomy.py | 24 + openedx_tagging/core/tagging/models.py | 102 ++ .../fixtures/language_taxonomy.yaml | 1297 +++++++++++++++++ .../system_defined.py | 29 + .../core/tagging/test_system_defined.py | 39 + 7 files changed, 1551 insertions(+) create mode 100644 openedx_tagging/core/tagging/management/commands/__init__.py create mode 100644 openedx_tagging/core/tagging/management/commands/build_language_fixture.py create mode 100644 openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py create mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml create mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py create mode 100644 tests/openedx_tagging/core/tagging/test_system_defined.py diff --git a/openedx_tagging/core/tagging/management/commands/__init__.py b/openedx_tagging/core/tagging/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py new file mode 100644 index 00000000..1b5f2697 --- /dev/null +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -0,0 +1,60 @@ +""" +Script that downloads all the ISO 639-1 languages and processes them +to write the fixture for the Language system-defined taxonomy. + +This function is intended to be used only once, +but can be edited in the future if more data needs to be added to the fixture. +""" +import urllib.request +import json + +from django.core.management.base import BaseCommand + +endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json" +output = "./openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml" +taxonomy = """\ +- model: oel_tagging.taxonomy + pk: 1 + fields: + name: Language + description: ISO 639-1 Languages + enabled: true + required: true + allow_multiple: false + allow_free_text: false +""" + +class Command(BaseCommand): + + def handle(self, **options): + json_data = self.download_json() + self.build_fixture(json_data) + + def download_json(self): + with urllib.request.urlopen(endpoint) as response: + json_data = response.read() + return json.loads(json_data) + + def build_fixture(self, json_data): + tag_pk = 1 + with open(output, 'w') as output_file: + output_file.writelines(taxonomy) + for lang_data in json_data: + lang_value = self.get_lang_value(lang_data) + lang_code = lang_data["alpha2"] + output_file.write("- model: oel_tagging.tag\n") + output_file.write(f" pk: {tag_pk}\n") + output_file.write(" fields:\n") + output_file.write(" taxonomy: 1\n") + output_file.write(" parent: null\n") + output_file.write(f" value: {lang_value}\n") + output_file.write(f" external_id: {lang_code}\n") + tag_pk += 1 + + def get_lang_value(self, lang_data): + """ + Gets the lang value. Some languages has many values. + """ + lang_list = lang_data["English"].split(';') + return lang_list[0] + \ No newline at end of file diff --git a/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py b/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py new file mode 100644 index 00000000..4c4ff74b --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.19 on 2023-07-04 20:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SystemTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.taxonomy',), + ), + ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index 3c8222bc..f16338ee 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -1,6 +1,7 @@ """ Tagging app data models """ import logging from typing import List, Type, Union +from enum import Enum from django.db import models from django.utils.module_loading import import_string @@ -18,6 +19,13 @@ # Will contain 0...TAXONOMY_MAX_DEPTH elements. Lineage = List[str] +class SystemDefinedTaxonomyTagsType(Enum): + """ + Tag types of system-defined taxonomies + """ + closed = 'closed' # Tags are created by fixtures/migrations + free_form = 'free_form' # Tags are free form + class Tag(models.Model): """ @@ -627,3 +635,97 @@ def copy(self, object_tag: "ObjectTag") -> "ObjectTag": self._value = object_tag._value self._name = object_tag._name return self + + def _check_object(self): + """ + Returns True if this ObjectTag has a valid object. + + Subclasses should override this method to perform any additional validation for the particular type of object tag. + """ + # Must have a valid object id/type: + return self.object_id and self.object_type + + +class ClosedObjectTag(OpenObjectTag): + """ + Object tags linked to a closed taxonomy, where the available tag value options are known. + """ + + class Meta: + proxy = True + + def _check_taxonomy(self): + """ + Returns True if this ObjectTag is linked to a closed taxonomy. + + Subclasses should override this method to perform any additional validation for the particular type of object tag. + """ + # Must be linked to a closed taxonomy + return self.taxonomy_id and not self.taxonomy.allow_free_text + + def _check_tag(self): + """ + Returns True if this ObjectTag has a valid tag. + + Subclasses should override this method to perform any additional validation for the particular type of object tag. + """ + # Closed taxonomies require a Tag + return bool(self.tag_id) + + def is_valid(self, check_taxonomy=True, check_tag=True, check_object=True) -> bool: + """ + Returns True if this ObjectTag is valid for use with a closed taxonomy. + + Subclasses should override this method to perform any additional validation for the particular type of object tag. + + If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. + If `check_tag` is False, then we skip validating the object tag's tag reference. + If `check_object` is False, then we skip validating the object ID/type. + """ + if not super().is_valid( + check_taxonomy=check_taxonomy, + check_tag=check_tag, + check_object=check_object, + ): + return False + + if check_tag and check_taxonomy and (self.tag.taxonomy_id != self.taxonomy_id): + return False + + return True + + +# Register the ObjectTag subclasses in reverse order of how we want them considered. +register_object_tag_class(OpenObjectTag) +register_object_tag_class(ClosedObjectTag) + +class SystemTaxonomy(Taxonomy): + """ + System-defined taxonomies are not editable by normal users; they're defined by fixtures/migrations, and may have + dynamically-determined Tags and ObjectTags. + """ + + @property + def system_defined(self) -> bool: + """ + This is a system-defined taxonomy. + """ + return True + + @property + def is_visible(self) -> bool: + """ + Controls the visibility of this taxonomy to content authors. + + This value is static and must be implemented per each system-defined taxonomy. + """ + raise NotImplementedError + + @property + def creation_type (self) -> SystemDefinedTaxonomyTagsType: + """ + Controls the behaviour of the tags on this taxonomy + + This value is static and must be implemented per each system-defined taxonomy. + """ + raise NotImplementedError diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml new file mode 100644 index 00000000..bb10d6c9 --- /dev/null +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml @@ -0,0 +1,1297 @@ +- model: oel_tagging.taxonomy + pk: 1 + fields: + name: Language + description: ISO 639-1 Languages + enabled: true + required: true + allow_multiple: false + allow_free_text: false +- model: oel_tagging.tag + pk: 1 + fields: + taxonomy: 1 + parent: null + value: Afar + external_id: aa +- model: oel_tagging.tag + pk: 2 + fields: + taxonomy: 1 + parent: null + value: Abkhazian + external_id: ab +- model: oel_tagging.tag + pk: 3 + fields: + taxonomy: 1 + parent: null + value: Avestan + external_id: ae +- model: oel_tagging.tag + pk: 4 + fields: + taxonomy: 1 + parent: null + value: Afrikaans + external_id: af +- model: oel_tagging.tag + pk: 5 + fields: + taxonomy: 1 + parent: null + value: Akan + external_id: ak +- model: oel_tagging.tag + pk: 6 + fields: + taxonomy: 1 + parent: null + value: Amharic + external_id: am +- model: oel_tagging.tag + pk: 7 + fields: + taxonomy: 1 + parent: null + value: Aragonese + external_id: an +- model: oel_tagging.tag + pk: 8 + fields: + taxonomy: 1 + parent: null + value: Arabic + external_id: ar +- model: oel_tagging.tag + pk: 9 + fields: + taxonomy: 1 + parent: null + value: Assamese + external_id: as +- model: oel_tagging.tag + pk: 10 + fields: + taxonomy: 1 + parent: null + value: Avaric + external_id: av +- model: oel_tagging.tag + pk: 11 + fields: + taxonomy: 1 + parent: null + value: Aymara + external_id: ay +- model: oel_tagging.tag + pk: 12 + fields: + taxonomy: 1 + parent: null + value: Azerbaijani + external_id: az +- model: oel_tagging.tag + pk: 13 + fields: + taxonomy: 1 + parent: null + value: Bashkir + external_id: ba +- model: oel_tagging.tag + pk: 14 + fields: + taxonomy: 1 + parent: null + value: Belarusian + external_id: be +- model: oel_tagging.tag + pk: 15 + fields: + taxonomy: 1 + parent: null + value: Bulgarian + external_id: bg +- model: oel_tagging.tag + pk: 16 + fields: + taxonomy: 1 + parent: null + value: Bihari languages + external_id: bh +- model: oel_tagging.tag + pk: 17 + fields: + taxonomy: 1 + parent: null + value: Bislama + external_id: bi +- model: oel_tagging.tag + pk: 18 + fields: + taxonomy: 1 + parent: null + value: Bambara + external_id: bm +- model: oel_tagging.tag + pk: 19 + fields: + taxonomy: 1 + parent: null + value: Bengali + external_id: bn +- model: oel_tagging.tag + pk: 20 + fields: + taxonomy: 1 + parent: null + value: Tibetan + external_id: bo +- model: oel_tagging.tag + pk: 21 + fields: + taxonomy: 1 + parent: null + value: Breton + external_id: br +- model: oel_tagging.tag + pk: 22 + fields: + taxonomy: 1 + parent: null + value: Bosnian + external_id: bs +- model: oel_tagging.tag + pk: 23 + fields: + taxonomy: 1 + parent: null + value: Catalan + external_id: ca +- model: oel_tagging.tag + pk: 24 + fields: + taxonomy: 1 + parent: null + value: Chechen + external_id: ce +- model: oel_tagging.tag + pk: 25 + fields: + taxonomy: 1 + parent: null + value: Chamorro + external_id: ch +- model: oel_tagging.tag + pk: 26 + fields: + taxonomy: 1 + parent: null + value: Corsican + external_id: co +- model: oel_tagging.tag + pk: 27 + fields: + taxonomy: 1 + parent: null + value: Cree + external_id: cr +- model: oel_tagging.tag + pk: 28 + fields: + taxonomy: 1 + parent: null + value: Czech + external_id: cs +- model: oel_tagging.tag + pk: 29 + fields: + taxonomy: 1 + parent: null + value: Church Slavic + external_id: cu +- model: oel_tagging.tag + pk: 30 + fields: + taxonomy: 1 + parent: null + value: Chuvash + external_id: cv +- model: oel_tagging.tag + pk: 31 + fields: + taxonomy: 1 + parent: null + value: Welsh + external_id: cy +- model: oel_tagging.tag + pk: 32 + fields: + taxonomy: 1 + parent: null + value: Danish + external_id: da +- model: oel_tagging.tag + pk: 33 + fields: + taxonomy: 1 + parent: null + value: German + external_id: de +- model: oel_tagging.tag + pk: 34 + fields: + taxonomy: 1 + parent: null + value: Divehi + external_id: dv +- model: oel_tagging.tag + pk: 35 + fields: + taxonomy: 1 + parent: null + value: Dzongkha + external_id: dz +- model: oel_tagging.tag + pk: 36 + fields: + taxonomy: 1 + parent: null + value: Ewe + external_id: ee +- model: oel_tagging.tag + pk: 37 + fields: + taxonomy: 1 + parent: null + value: Greek, Modern (1453-) + external_id: el +- model: oel_tagging.tag + pk: 38 + fields: + taxonomy: 1 + parent: null + value: English + external_id: en +- model: oel_tagging.tag + pk: 39 + fields: + taxonomy: 1 + parent: null + value: Esperanto + external_id: eo +- model: oel_tagging.tag + pk: 40 + fields: + taxonomy: 1 + parent: null + value: Spanish + external_id: es +- model: oel_tagging.tag + pk: 41 + fields: + taxonomy: 1 + parent: null + value: Estonian + external_id: et +- model: oel_tagging.tag + pk: 42 + fields: + taxonomy: 1 + parent: null + value: Basque + external_id: eu +- model: oel_tagging.tag + pk: 43 + fields: + taxonomy: 1 + parent: null + value: Persian + external_id: fa +- model: oel_tagging.tag + pk: 44 + fields: + taxonomy: 1 + parent: null + value: Fulah + external_id: ff +- model: oel_tagging.tag + pk: 45 + fields: + taxonomy: 1 + parent: null + value: Finnish + external_id: fi +- model: oel_tagging.tag + pk: 46 + fields: + taxonomy: 1 + parent: null + value: Fijian + external_id: fj +- model: oel_tagging.tag + pk: 47 + fields: + taxonomy: 1 + parent: null + value: Faroese + external_id: fo +- model: oel_tagging.tag + pk: 48 + fields: + taxonomy: 1 + parent: null + value: French + external_id: fr +- model: oel_tagging.tag + pk: 49 + fields: + taxonomy: 1 + parent: null + value: Western Frisian + external_id: fy +- model: oel_tagging.tag + pk: 50 + fields: + taxonomy: 1 + parent: null + value: Irish + external_id: ga +- model: oel_tagging.tag + pk: 51 + fields: + taxonomy: 1 + parent: null + value: Gaelic + external_id: gd +- model: oel_tagging.tag + pk: 52 + fields: + taxonomy: 1 + parent: null + value: Galician + external_id: gl +- model: oel_tagging.tag + pk: 53 + fields: + taxonomy: 1 + parent: null + value: Guarani + external_id: gn +- model: oel_tagging.tag + pk: 54 + fields: + taxonomy: 1 + parent: null + value: Gujarati + external_id: gu +- model: oel_tagging.tag + pk: 55 + fields: + taxonomy: 1 + parent: null + value: Manx + external_id: gv +- model: oel_tagging.tag + pk: 56 + fields: + taxonomy: 1 + parent: null + value: Hausa + external_id: ha +- model: oel_tagging.tag + pk: 57 + fields: + taxonomy: 1 + parent: null + value: Hebrew + external_id: he +- model: oel_tagging.tag + pk: 58 + fields: + taxonomy: 1 + parent: null + value: Hindi + external_id: hi +- model: oel_tagging.tag + pk: 59 + fields: + taxonomy: 1 + parent: null + value: Hiri Motu + external_id: ho +- model: oel_tagging.tag + pk: 60 + fields: + taxonomy: 1 + parent: null + value: Croatian + external_id: hr +- model: oel_tagging.tag + pk: 61 + fields: + taxonomy: 1 + parent: null + value: Haitian + external_id: ht +- model: oel_tagging.tag + pk: 62 + fields: + taxonomy: 1 + parent: null + value: Hungarian + external_id: hu +- model: oel_tagging.tag + pk: 63 + fields: + taxonomy: 1 + parent: null + value: Armenian + external_id: hy +- model: oel_tagging.tag + pk: 64 + fields: + taxonomy: 1 + parent: null + value: Herero + external_id: hz +- model: oel_tagging.tag + pk: 65 + fields: + taxonomy: 1 + parent: null + value: Interlingua (International Auxiliary Language Association) + external_id: ia +- model: oel_tagging.tag + pk: 66 + fields: + taxonomy: 1 + parent: null + value: Indonesian + external_id: id +- model: oel_tagging.tag + pk: 67 + fields: + taxonomy: 1 + parent: null + value: Interlingue + external_id: ie +- model: oel_tagging.tag + pk: 68 + fields: + taxonomy: 1 + parent: null + value: Igbo + external_id: ig +- model: oel_tagging.tag + pk: 69 + fields: + taxonomy: 1 + parent: null + value: Sichuan Yi + external_id: ii +- model: oel_tagging.tag + pk: 70 + fields: + taxonomy: 1 + parent: null + value: Inupiaq + external_id: ik +- model: oel_tagging.tag + pk: 71 + fields: + taxonomy: 1 + parent: null + value: Ido + external_id: io +- model: oel_tagging.tag + pk: 72 + fields: + taxonomy: 1 + parent: null + value: Icelandic + external_id: is +- model: oel_tagging.tag + pk: 73 + fields: + taxonomy: 1 + parent: null + value: Italian + external_id: it +- model: oel_tagging.tag + pk: 74 + fields: + taxonomy: 1 + parent: null + value: Inuktitut + external_id: iu +- model: oel_tagging.tag + pk: 75 + fields: + taxonomy: 1 + parent: null + value: Japanese + external_id: ja +- model: oel_tagging.tag + pk: 76 + fields: + taxonomy: 1 + parent: null + value: Javanese + external_id: jv +- model: oel_tagging.tag + pk: 77 + fields: + taxonomy: 1 + parent: null + value: Georgian + external_id: ka +- model: oel_tagging.tag + pk: 78 + fields: + taxonomy: 1 + parent: null + value: Kongo + external_id: kg +- model: oel_tagging.tag + pk: 79 + fields: + taxonomy: 1 + parent: null + value: Kikuyu + external_id: ki +- model: oel_tagging.tag + pk: 80 + fields: + taxonomy: 1 + parent: null + value: Kuanyama + external_id: kj +- model: oel_tagging.tag + pk: 81 + fields: + taxonomy: 1 + parent: null + value: Kazakh + external_id: kk +- model: oel_tagging.tag + pk: 82 + fields: + taxonomy: 1 + parent: null + value: Kalaallisut + external_id: kl +- model: oel_tagging.tag + pk: 83 + fields: + taxonomy: 1 + parent: null + value: Central Khmer + external_id: km +- model: oel_tagging.tag + pk: 84 + fields: + taxonomy: 1 + parent: null + value: Kannada + external_id: kn +- model: oel_tagging.tag + pk: 85 + fields: + taxonomy: 1 + parent: null + value: Korean + external_id: ko +- model: oel_tagging.tag + pk: 86 + fields: + taxonomy: 1 + parent: null + value: Kanuri + external_id: kr +- model: oel_tagging.tag + pk: 87 + fields: + taxonomy: 1 + parent: null + value: Kashmiri + external_id: ks +- model: oel_tagging.tag + pk: 88 + fields: + taxonomy: 1 + parent: null + value: Kurdish + external_id: ku +- model: oel_tagging.tag + pk: 89 + fields: + taxonomy: 1 + parent: null + value: Komi + external_id: kv +- model: oel_tagging.tag + pk: 90 + fields: + taxonomy: 1 + parent: null + value: Cornish + external_id: kw +- model: oel_tagging.tag + pk: 91 + fields: + taxonomy: 1 + parent: null + value: Kirghiz + external_id: ky +- model: oel_tagging.tag + pk: 92 + fields: + taxonomy: 1 + parent: null + value: Latin + external_id: la +- model: oel_tagging.tag + pk: 93 + fields: + taxonomy: 1 + parent: null + value: Luxembourgish + external_id: lb +- model: oel_tagging.tag + pk: 94 + fields: + taxonomy: 1 + parent: null + value: Ganda + external_id: lg +- model: oel_tagging.tag + pk: 95 + fields: + taxonomy: 1 + parent: null + value: Limburgan + external_id: li +- model: oel_tagging.tag + pk: 96 + fields: + taxonomy: 1 + parent: null + value: Lingala + external_id: ln +- model: oel_tagging.tag + pk: 97 + fields: + taxonomy: 1 + parent: null + value: Lao + external_id: lo +- model: oel_tagging.tag + pk: 98 + fields: + taxonomy: 1 + parent: null + value: Lithuanian + external_id: lt +- model: oel_tagging.tag + pk: 99 + fields: + taxonomy: 1 + parent: null + value: Luba-Katanga + external_id: lu +- model: oel_tagging.tag + pk: 100 + fields: + taxonomy: 1 + parent: null + value: Latvian + external_id: lv +- model: oel_tagging.tag + pk: 101 + fields: + taxonomy: 1 + parent: null + value: Malagasy + external_id: mg +- model: oel_tagging.tag + pk: 102 + fields: + taxonomy: 1 + parent: null + value: Marshallese + external_id: mh +- model: oel_tagging.tag + pk: 103 + fields: + taxonomy: 1 + parent: null + value: Maori + external_id: mi +- model: oel_tagging.tag + pk: 104 + fields: + taxonomy: 1 + parent: null + value: Macedonian + external_id: mk +- model: oel_tagging.tag + pk: 105 + fields: + taxonomy: 1 + parent: null + value: Malayalam + external_id: ml +- model: oel_tagging.tag + pk: 106 + fields: + taxonomy: 1 + parent: null + value: Mongolian + external_id: mn +- model: oel_tagging.tag + pk: 107 + fields: + taxonomy: 1 + parent: null + value: Marathi + external_id: mr +- model: oel_tagging.tag + pk: 108 + fields: + taxonomy: 1 + parent: null + value: Malay + external_id: ms +- model: oel_tagging.tag + pk: 109 + fields: + taxonomy: 1 + parent: null + value: Maltese + external_id: mt +- model: oel_tagging.tag + pk: 110 + fields: + taxonomy: 1 + parent: null + value: Burmese + external_id: my +- model: oel_tagging.tag + pk: 111 + fields: + taxonomy: 1 + parent: null + value: Nauru + external_id: na +- model: oel_tagging.tag + pk: 112 + fields: + taxonomy: 1 + parent: null + value: Bokmål, Norwegian + external_id: nb +- model: oel_tagging.tag + pk: 113 + fields: + taxonomy: 1 + parent: null + value: Ndebele, North + external_id: nd +- model: oel_tagging.tag + pk: 114 + fields: + taxonomy: 1 + parent: null + value: Nepali + external_id: ne +- model: oel_tagging.tag + pk: 115 + fields: + taxonomy: 1 + parent: null + value: Ndonga + external_id: ng +- model: oel_tagging.tag + pk: 116 + fields: + taxonomy: 1 + parent: null + value: Dutch + external_id: nl +- model: oel_tagging.tag + pk: 117 + fields: + taxonomy: 1 + parent: null + value: Norwegian Nynorsk + external_id: nn +- model: oel_tagging.tag + pk: 118 + fields: + taxonomy: 1 + parent: null + value: Norwegian + external_id: no +- model: oel_tagging.tag + pk: 119 + fields: + taxonomy: 1 + parent: null + value: Ndebele, South + external_id: nr +- model: oel_tagging.tag + pk: 120 + fields: + taxonomy: 1 + parent: null + value: Navajo + external_id: nv +- model: oel_tagging.tag + pk: 121 + fields: + taxonomy: 1 + parent: null + value: Chichewa + external_id: ny +- model: oel_tagging.tag + pk: 122 + fields: + taxonomy: 1 + parent: null + value: Occitan (post 1500) + external_id: oc +- model: oel_tagging.tag + pk: 123 + fields: + taxonomy: 1 + parent: null + value: Ojibwa + external_id: oj +- model: oel_tagging.tag + pk: 124 + fields: + taxonomy: 1 + parent: null + value: Oromo + external_id: om +- model: oel_tagging.tag + pk: 125 + fields: + taxonomy: 1 + parent: null + value: Oriya + external_id: or +- model: oel_tagging.tag + pk: 126 + fields: + taxonomy: 1 + parent: null + value: Ossetian + external_id: os +- model: oel_tagging.tag + pk: 127 + fields: + taxonomy: 1 + parent: null + value: Panjabi + external_id: pa +- model: oel_tagging.tag + pk: 128 + fields: + taxonomy: 1 + parent: null + value: Pali + external_id: pi +- model: oel_tagging.tag + pk: 129 + fields: + taxonomy: 1 + parent: null + value: Polish + external_id: pl +- model: oel_tagging.tag + pk: 130 + fields: + taxonomy: 1 + parent: null + value: Pushto + external_id: ps +- model: oel_tagging.tag + pk: 131 + fields: + taxonomy: 1 + parent: null + value: Portuguese + external_id: pt +- model: oel_tagging.tag + pk: 132 + fields: + taxonomy: 1 + parent: null + value: Quechua + external_id: qu +- model: oel_tagging.tag + pk: 133 + fields: + taxonomy: 1 + parent: null + value: Romansh + external_id: rm +- model: oel_tagging.tag + pk: 134 + fields: + taxonomy: 1 + parent: null + value: Rundi + external_id: rn +- model: oel_tagging.tag + pk: 135 + fields: + taxonomy: 1 + parent: null + value: Romanian + external_id: ro +- model: oel_tagging.tag + pk: 136 + fields: + taxonomy: 1 + parent: null + value: Russian + external_id: ru +- model: oel_tagging.tag + pk: 137 + fields: + taxonomy: 1 + parent: null + value: Kinyarwanda + external_id: rw +- model: oel_tagging.tag + pk: 138 + fields: + taxonomy: 1 + parent: null + value: Sanskrit + external_id: sa +- model: oel_tagging.tag + pk: 139 + fields: + taxonomy: 1 + parent: null + value: Sardinian + external_id: sc +- model: oel_tagging.tag + pk: 140 + fields: + taxonomy: 1 + parent: null + value: Sindhi + external_id: sd +- model: oel_tagging.tag + pk: 141 + fields: + taxonomy: 1 + parent: null + value: Northern Sami + external_id: se +- model: oel_tagging.tag + pk: 142 + fields: + taxonomy: 1 + parent: null + value: Sango + external_id: sg +- model: oel_tagging.tag + pk: 143 + fields: + taxonomy: 1 + parent: null + value: Sinhala + external_id: si +- model: oel_tagging.tag + pk: 144 + fields: + taxonomy: 1 + parent: null + value: Slovak + external_id: sk +- model: oel_tagging.tag + pk: 145 + fields: + taxonomy: 1 + parent: null + value: Slovenian + external_id: sl +- model: oel_tagging.tag + pk: 146 + fields: + taxonomy: 1 + parent: null + value: Samoan + external_id: sm +- model: oel_tagging.tag + pk: 147 + fields: + taxonomy: 1 + parent: null + value: Shona + external_id: sn +- model: oel_tagging.tag + pk: 148 + fields: + taxonomy: 1 + parent: null + value: Somali + external_id: so +- model: oel_tagging.tag + pk: 149 + fields: + taxonomy: 1 + parent: null + value: Albanian + external_id: sq +- model: oel_tagging.tag + pk: 150 + fields: + taxonomy: 1 + parent: null + value: Serbian + external_id: sr +- model: oel_tagging.tag + pk: 151 + fields: + taxonomy: 1 + parent: null + value: Swati + external_id: ss +- model: oel_tagging.tag + pk: 152 + fields: + taxonomy: 1 + parent: null + value: Sotho, Southern + external_id: st +- model: oel_tagging.tag + pk: 153 + fields: + taxonomy: 1 + parent: null + value: Sundanese + external_id: su +- model: oel_tagging.tag + pk: 154 + fields: + taxonomy: 1 + parent: null + value: Swedish + external_id: sv +- model: oel_tagging.tag + pk: 155 + fields: + taxonomy: 1 + parent: null + value: Swahili + external_id: sw +- model: oel_tagging.tag + pk: 156 + fields: + taxonomy: 1 + parent: null + value: Tamil + external_id: ta +- model: oel_tagging.tag + pk: 157 + fields: + taxonomy: 1 + parent: null + value: Telugu + external_id: te +- model: oel_tagging.tag + pk: 158 + fields: + taxonomy: 1 + parent: null + value: Tajik + external_id: tg +- model: oel_tagging.tag + pk: 159 + fields: + taxonomy: 1 + parent: null + value: Thai + external_id: th +- model: oel_tagging.tag + pk: 160 + fields: + taxonomy: 1 + parent: null + value: Tigrinya + external_id: ti +- model: oel_tagging.tag + pk: 161 + fields: + taxonomy: 1 + parent: null + value: Turkmen + external_id: tk +- model: oel_tagging.tag + pk: 162 + fields: + taxonomy: 1 + parent: null + value: Tagalog + external_id: tl +- model: oel_tagging.tag + pk: 163 + fields: + taxonomy: 1 + parent: null + value: Tswana + external_id: tn +- model: oel_tagging.tag + pk: 164 + fields: + taxonomy: 1 + parent: null + value: Tonga (Tonga Islands) + external_id: to +- model: oel_tagging.tag + pk: 165 + fields: + taxonomy: 1 + parent: null + value: Turkish + external_id: tr +- model: oel_tagging.tag + pk: 166 + fields: + taxonomy: 1 + parent: null + value: Tsonga + external_id: ts +- model: oel_tagging.tag + pk: 167 + fields: + taxonomy: 1 + parent: null + value: Tatar + external_id: tt +- model: oel_tagging.tag + pk: 168 + fields: + taxonomy: 1 + parent: null + value: Twi + external_id: tw +- model: oel_tagging.tag + pk: 169 + fields: + taxonomy: 1 + parent: null + value: Tahitian + external_id: ty +- model: oel_tagging.tag + pk: 170 + fields: + taxonomy: 1 + parent: null + value: Uighur + external_id: ug +- model: oel_tagging.tag + pk: 171 + fields: + taxonomy: 1 + parent: null + value: Ukrainian + external_id: uk +- model: oel_tagging.tag + pk: 172 + fields: + taxonomy: 1 + parent: null + value: Urdu + external_id: ur +- model: oel_tagging.tag + pk: 173 + fields: + taxonomy: 1 + parent: null + value: Uzbek + external_id: uz +- model: oel_tagging.tag + pk: 174 + fields: + taxonomy: 1 + parent: null + value: Venda + external_id: ve +- model: oel_tagging.tag + pk: 175 + fields: + taxonomy: 1 + parent: null + value: Vietnamese + external_id: vi +- model: oel_tagging.tag + pk: 176 + fields: + taxonomy: 1 + parent: null + value: Volapük + external_id: vo +- model: oel_tagging.tag + pk: 177 + fields: + taxonomy: 1 + parent: null + value: Walloon + external_id: wa +- model: oel_tagging.tag + pk: 178 + fields: + taxonomy: 1 + parent: null + value: Wolof + external_id: wo +- model: oel_tagging.tag + pk: 179 + fields: + taxonomy: 1 + parent: null + value: Xhosa + external_id: xh +- model: oel_tagging.tag + pk: 180 + fields: + taxonomy: 1 + parent: null + value: Yiddish + external_id: yi +- model: oel_tagging.tag + pk: 181 + fields: + taxonomy: 1 + parent: null + value: Yoruba + external_id: yo +- model: oel_tagging.tag + pk: 182 + fields: + taxonomy: 1 + parent: null + value: Zhuang + external_id: za +- model: oel_tagging.tag + pk: 183 + fields: + taxonomy: 1 + parent: null + value: Chinese + external_id: zh +- model: oel_tagging.tag + pk: 184 + fields: + taxonomy: 1 + parent: null + value: Zulu + external_id: zu diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py b/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py new file mode 100644 index 00000000..f500d5f5 --- /dev/null +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py @@ -0,0 +1,29 @@ +from openedx_tagging.core.tagging.models import SystemTaxonomy, SystemDefinedTaxonomyTagsType + + +class LanguageTaxonomy(SystemTaxonomy): + + class Meta: + proxy = True + + @property + def is_visible(self) -> bool: + raise True + + @property + def creation_type (self) -> SystemDefinedTaxonomyTagsType: + return SystemDefinedTaxonomyTagsType.closed + + +class AuthorTaxonomy(SystemTaxonomy): + + class Meta: + proxy = True + + @property + def is_visible(self) -> bool: + raise False + + @property + def creation_type (self) -> SystemDefinedTaxonomyTagsType: + return SystemDefinedTaxonomyTagsType.free_form diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py new file mode 100644 index 00000000..438a6394 --- /dev/null +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -0,0 +1,39 @@ +""" Test the System-defined taxonomies """ + +import ddt +from django.test.testcases import TestCase + +from openedx_tagging.core.tagging.models import Taxonomy + + +class TestSystemDefinedTaxonomyMixin: + """ + Mixin used on system-defined taxonomy tests + """ + + fixtures = ["openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml"] + + def setUp(self): + super().setUp() + self.language_taxonomy = Taxonomy.objects.get(pk=1) + + +@ddt.ddt +class TestLanguageTaxonomy(TestSystemDefinedTaxonomyMixin, TestCase): + """ + Test the Language Taxonomy + """ + + @ddt.data( + ('en', 'English'), + ('az', 'Azerbaijani'), + ('id', 'Indonesian'), + ('qu', 'Quechua'), + ('zu', 'Zulu'), + ) + @ddt.unpack + def test_fixture(self, lang_code, lang_value): + self.assertEqual(self.language_taxonomy.name, 'Language') + lang = self.language_taxonomy.tag_set.get(external_id=lang_code) + self.assertEqual(lang.value, lang_value) + \ No newline at end of file From 55636f7d39c1591fa8ce11d761229332d8971241 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 5 Jul 2023 16:31:41 -0500 Subject: [PATCH 04/20] feat: Language and Author taxonomies --- .../commands/build_language_fixture.py | 12 -- .../fixtures/language_taxonomy.yaml | 9 - .../fixtures/taxonomies.yaml | 18 ++ .../system_defined.py | 161 ++++++++++++++++- .../core/tagging/test_system_defined.py | 163 ++++++++++++++++-- 5 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py index 1b5f2697..fef118a5 100644 --- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -12,17 +12,6 @@ endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json" output = "./openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml" -taxonomy = """\ -- model: oel_tagging.taxonomy - pk: 1 - fields: - name: Language - description: ISO 639-1 Languages - enabled: true - required: true - allow_multiple: false - allow_free_text: false -""" class Command(BaseCommand): @@ -38,7 +27,6 @@ def download_json(self): def build_fixture(self, json_data): tag_pk = 1 with open(output, 'w') as output_file: - output_file.writelines(taxonomy) for lang_data in json_data: lang_value = self.get_lang_value(lang_data) lang_code = lang_data["alpha2"] diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml index bb10d6c9..efe84cdb 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml @@ -1,12 +1,3 @@ -- model: oel_tagging.taxonomy - pk: 1 - fields: - name: Language - description: ISO 639-1 Languages - enabled: true - required: true - allow_multiple: false - allow_free_text: false - model: oel_tagging.tag pk: 1 fields: diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml new file mode 100644 index 00000000..692001f7 --- /dev/null +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml @@ -0,0 +1,18 @@ +- model: oel_tagging.taxonomy + pk: 1 + fields: + name: Language + description: ISO 639-1 Languages + enabled: true + required: true + allow_multiple: false + allow_free_text: false +- model: oel_tagging.taxonomy + pk: 2 + fields: + name: Author + description: User Ids of the platform + enabled: true + required: true + allow_multiple: false + allow_free_text: true diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py b/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py index f500d5f5..f25300c5 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py @@ -1,7 +1,63 @@ -from openedx_tagging.core.tagging.models import SystemTaxonomy, SystemDefinedTaxonomyTagsType +from typing import List + +from django.conf import settings +from django.contrib.auth import get_user_model + +from openedx_tagging.core.tagging.models import ObjectTag, Tag, SystemTaxonomy, SystemDefinedTaxonomyTagsType + +class IdBasedSystemTaxonomyMixin: + """ + Mixin for ID based taxonomies. + + Used to build tags with a prefix. E.x user: + """ + + prefix = "" + separator = ":" + + @property + def creation_type(self) -> SystemDefinedTaxonomyTagsType: + return SystemDefinedTaxonomyTagsType.free_form + + def get_id(self, tag_value): + """ + Get the id part of the tag + """ + return tag_value.split(self.separator)[1] + + def build_tag(self, id): + """ + Build the tag with the prefix and the separator + """ + return f"{self.prefix}{self.separator}{id}" + + def tag_object( + self, tags: List, object_id: str, object_type: str + ) -> List["ObjectTag"]: + """ + Overwriten the tag_object of the Taxonomy model + + Preprocess and build the new tags + """ + new_tags = [] + for tag in tags: + new_tags.append(self.build_tag(tag)) + + return super().tag_object(tags, object_id, object_type) class LanguageTaxonomy(SystemTaxonomy): + """ + Language system-defined taxonomy. + + Tag creation: Hardcoded by fixtures + Visible: True + Allow multiple: False + Tags: ISO 639-1 Languages + + The tags are filtered and validated taking into account the + languages available in Django LANGUAGES settings var + """ class Meta: proxy = True @@ -11,19 +67,108 @@ def is_visible(self) -> bool: raise True @property - def creation_type (self) -> SystemDefinedTaxonomyTagsType: + def creation_type(self) -> SystemDefinedTaxonomyTagsType: return SystemDefinedTaxonomyTagsType.closed + + def get_available_languages(self) -> List[str]: + """ + Get the available languages from Django LANGUAGE. + """ + langs = set() + for django_lang in settings.LANGUAGES: + # Split to get the language part + langs.add(django_lang[0].split('-')[0]) + return langs + def get_tags(self) -> List[Tag]: + """ + Returns a list of tags of the available languages. + """ + tags = super().get_tags() + result = [] + available_langs = self.get_available_languages() + for tag in tags: + if tag.external_id in available_langs: + result.append(tag) + return result -class AuthorTaxonomy(SystemTaxonomy): + def validate_object_tag( + self, + object_tag: "ObjectTag", + check_taxonomy=True, + check_tag=True, + check_object=True, + ) -> bool: + """ + Makes the normal object validation and validates if the + tag is an available language + """ + validation = super().validate_object_tag( + object_tag, + check_taxonomy, + check_tag, + check_object, + ) + if not validation: + return False + + available_langs = self.get_available_languages() + + # Must be linked to a tag and must be an available language + if not object_tag.tag or not object_tag.tag.external_id in available_langs: + return False + + return True + + +class AuthorTaxonomy(SystemTaxonomy, IdBasedSystemTaxonomyMixin): + """ + Author system-defined taxonomy + + Tag creation: Free form + Visible: False + Allow multiple: False + Tags: Id's of users in the form: author: + """ class Meta: proxy = True - + + prefix = "author" + @property def is_visible(self) -> bool: raise False - - @property - def creation_type (self) -> SystemDefinedTaxonomyTagsType: - return SystemDefinedTaxonomyTagsType.free_form + + def validate_object_tag( + self, + object_tag: "ObjectTag", + check_taxonomy=True, + check_tag=True, + check_object=True, + ) -> bool: + """ + Makes the normal object validation and validates if the user exists + """ + validation = super().validate_object_tag( + object_tag, + check_taxonomy, + check_tag, + check_object, + ) + if not validation: + return False + + user_id = self.get_id(object_tag.value) + + # The user must exists + if not self._user_exists(user_id): + return False + + return True + + def _user_exists(self, user_id): + """ + Verify if an user exists + """ + return get_user_model().objects.filter(id=user_id).exists() diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index 438a6394..94b93422 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -1,39 +1,172 @@ """ Test the System-defined taxonomies """ import ddt -from django.test.testcases import TestCase -from openedx_tagging.core.tagging.models import Taxonomy +from django.contrib.auth import get_user_model +from django.test.testcases import TestCase, override_settings +from openedx_tagging.core.tagging.models import Taxonomy, ObjectTag, Tag +from openedx_tagging.core.tagging.system_defined_taxonomies.system_defined import ( + LanguageTaxonomy, + AuthorTaxonomy +) + +fixtures_path = "openedx_tagging/core/tagging/system_defined_taxonomies/fixtures" +test_languages = [ + ('en', 'English'), + ('az', 'Azerbaijani'), + ('id', 'Indonesian'), + ('qu', 'Quechua'), + ('zu', 'Zulu'), +] class TestSystemDefinedTaxonomyMixin: """ Mixin used on system-defined taxonomy tests """ - fixtures = ["openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml"] + fixtures = [f"{fixtures_path}/taxonomies.yaml"] def setUp(self): super().setUp() - self.language_taxonomy = Taxonomy.objects.get(pk=1) + temp_taxonomy = Taxonomy.objects.get(pk=1) + self.language_taxonomy = LanguageTaxonomy() + self.language_taxonomy.__dict__ = temp_taxonomy.__dict__ + temp_taxonomy = Taxonomy.objects.get(pk=2) + self.author_taxonomy = AuthorTaxonomy() + self.author_taxonomy.__dict__ = temp_taxonomy.__dict__ @ddt.ddt +@override_settings(LANGUAGES=test_languages) class TestLanguageTaxonomy(TestSystemDefinedTaxonomyMixin, TestCase): """ Test the Language Taxonomy """ - @ddt.data( - ('en', 'English'), - ('az', 'Azerbaijani'), - ('id', 'Indonesian'), - ('qu', 'Quechua'), - ('zu', 'Zulu'), + def setUp(self): + super().setUp() + self.expected_langs_ids = sorted([item[0] for item in test_languages]) + self.expected_langs_values = sorted([item[1] for item in test_languages]) + self.english_tag = Tag.objects.get(value="English") + self.spanish_tag = Tag.objects.get(value="Spanish") + + fixtures = ( + TestSystemDefinedTaxonomyMixin.fixtures + + [f"{fixtures_path}/language_taxonomy.yaml"] ) - @ddt.unpack - def test_fixture(self, lang_code, lang_value): + + def test_fixture(self): self.assertEqual(self.language_taxonomy.name, 'Language') - lang = self.language_taxonomy.tag_set.get(external_id=lang_code) - self.assertEqual(lang.value, lang_value) - \ No newline at end of file + for lang_code, lang_value in test_languages: + lang = self.language_taxonomy.tag_set.get(external_id=lang_code) + self.assertEqual(lang.value, lang_value) + + def test_get_available_languages(self): + langs = self.language_taxonomy.get_available_languages() + self.assertEqual(sorted(langs), self.expected_langs_ids) + + def test_get_tags(self): + tags = self.language_taxonomy.get_tags() + for tag in tags: + self.assertIn(tag.value, self.expected_langs_values) + self.assertEqual(tag.annotated_field, 0) # Checking depth + + def test_validate_object_tag(self): + object_id = 'object_id' + invalid_object_tag_1 = ObjectTag( + taxonomy=self.language_taxonomy, + object_id=object_id, + tag=self.spanish_tag, + ) + invalid_object_tag_2 = ObjectTag( + object_id=object_id, + ) + valid_object_tag = ObjectTag( + taxonomy=self.language_taxonomy, + object_id=object_id, + tag=self.english_tag, + ) + + # Invalid. Language is not available + self.language_taxonomy.validate_object_tag( + object_tag=invalid_object_tag_1, + check_taxonomy=False, + check_tag=False, + check_object=False, + ) + + # Invalid. Testing normal object validations + self.language_taxonomy.validate_object_tag( + object_tag=invalid_object_tag_2, + check_taxonomy=True, + check_tag=False, + check_object=False, + ) + + # Valid + self.language_taxonomy.validate_object_tag( + object_tag=valid_object_tag, + check_taxonomy=False, + check_tag=False, + check_object=False, + ) + + +class TestAuthorTaxonomy(TestSystemDefinedTaxonomyMixin, TestCase): + """ + Test the Author Taxonomy + """ + + def setUp(self): + super().setUp() + self.user = get_user_model()( + username='test', + email='test@test.com', + ) + self.user.save() + + def test_user_exists(self): + self.assertFalse(self.author_taxonomy._user_exists(100)) + self.assertTrue(self.author_taxonomy._user_exists(self.user.id)) + + def test_validate_object_tag(self): + object_id = 'object_id' + invalid_object_tag_1 = ObjectTag( + taxonomy=self.author_taxonomy, + object_id=object_id, + _value="author:100" + ) + invalid_object_tag_2 = ObjectTag( + object_id=object_id, + _value="author:100" + ) + valid_object_tag = ObjectTag( + taxonomy=self.author_taxonomy, + object_id=object_id, + _value=f"author:{self.user.id}" + ) + + # Invalid. User doesn't exist + self.assertFalse(self.author_taxonomy.validate_object_tag( + object_tag=invalid_object_tag_1, + check_taxonomy=False, + check_tag=False, + check_object=False, + )) + + # Invalid. Testing normal object validations + self.assertFalse(self.author_taxonomy.validate_object_tag( + object_tag=invalid_object_tag_2, + check_taxonomy=True, + check_tag=False, + check_object=False, + )) + + # Valid + self.assertTrue(self.author_taxonomy.validate_object_tag( + object_tag=valid_object_tag, + check_taxonomy=False, + check_tag=False, + check_object=False, + )) From 327bb58ae562aa29cd0211d4a2cbb3df3bcf4f38 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 7 Jul 2023 18:04:54 -0500 Subject: [PATCH 05/20] feat: Generic system defined object tags and Language object tag added --- .../tagging/migrations/0002_systemtaxonomy.py | 24 -- openedx_tagging/core/tagging/models.py | 40 +-- .../fixtures/taxonomies.yaml | 2 +- .../system_defined_taxonomies/object_tags.py | 172 ++++++++++ .../system_defined.py | 174 ----------- .../core/fixtures/system_defined.yaml | 10 + .../core/fixtures/tagging.yaml | 10 + .../openedx_tagging/core/tagging/test_api.py | 15 +- .../core/tagging/test_models.py | 1 + .../core/tagging/test_system_defined.py | 294 +++++++++++------- 10 files changed, 385 insertions(+), 357 deletions(-) delete mode 100644 openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py create mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py delete mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py create mode 100644 tests/openedx_tagging/core/fixtures/system_defined.yaml diff --git a/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py b/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py deleted file mode 100644 index 4c4ff74b..00000000 --- a/openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.2.19 on 2023-07-04 20:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('oel_tagging', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='SystemTaxonomy', - fields=[ - ], - options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], - }, - bases=('oel_tagging.taxonomy',), - ), - ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index f16338ee..add3ac33 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -19,13 +19,6 @@ # Will contain 0...TAXONOMY_MAX_DEPTH elements. Lineage = List[str] -class SystemDefinedTaxonomyTagsType(Enum): - """ - Tag types of system-defined taxonomies - """ - closed = 'closed' # Tags are created by fixtures/migrations - free_form = 'free_form' # Tags are free form - class Tag(models.Model): """ @@ -644,6 +637,9 @@ def _check_object(self): """ # Must have a valid object id/type: return self.object_id and self.object_type + + def _check_tag(self): + return self.value class ClosedObjectTag(OpenObjectTag): @@ -699,33 +695,3 @@ def is_valid(self, check_taxonomy=True, check_tag=True, check_object=True) -> bo register_object_tag_class(OpenObjectTag) register_object_tag_class(ClosedObjectTag) -class SystemTaxonomy(Taxonomy): - """ - System-defined taxonomies are not editable by normal users; they're defined by fixtures/migrations, and may have - dynamically-determined Tags and ObjectTags. - """ - - @property - def system_defined(self) -> bool: - """ - This is a system-defined taxonomy. - """ - return True - - @property - def is_visible(self) -> bool: - """ - Controls the visibility of this taxonomy to content authors. - - This value is static and must be implemented per each system-defined taxonomy. - """ - raise NotImplementedError - - @property - def creation_type (self) -> SystemDefinedTaxonomyTagsType: - """ - Controls the behaviour of the tags on this taxonomy - - This value is static and must be implemented per each system-defined taxonomy. - """ - raise NotImplementedError diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml index 692001f7..12a2b43b 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml @@ -1,7 +1,7 @@ - model: oel_tagging.taxonomy pk: 1 fields: - name: Language + name: Languages description: ISO 639-1 Languages enabled: true required: true diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py new file mode 100644 index 00000000..d4cc5079 --- /dev/null +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py @@ -0,0 +1,172 @@ +from typing import List + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import FieldDoesNotExist +from django.db import models + + +from openedx_tagging.core.tagging.models import ( + Tag, + Taxonomy, + OpenObjectTag, + ClosedObjectTag, +) +from openedx_tagging.core.tagging.registry import register_object_tag_class + + +class OpenSystemObjectTag(OpenObjectTag): + """ + Free-text object tag used on system-defined taxonomies + """ + + class Meta: + proxy = True + + @classmethod + def valid_for(cls, taxonomy: Taxonomy = None, **kwars): + """ + Returns True if the the taxonomy is system-defined + """ + return super().valid_for(taxonomy=taxonomy) and taxonomy.system_defined + +class ClosedSystemObjectTag(ClosedObjectTag): + """ + Object tags linked to a closed system-taxonomy + """ + + class Meta: + proxy = True + + @classmethod + def valid_for(cls, taxonomy: Taxonomy = None, tag: Tag = None, **kargs): + """ + Returns True if the the taxonomy is system-defined + """ + return super().valid_for(taxonomy=taxonomy, tag=tag) and taxonomy.system_defined + + +class ModelObjectTag(OpenSystemObjectTag): + """ + Object tags used with tags that relate to the id of a model + + This object tag class is not registered as it needs to have an associated model + """ + + class Meta: + proxy = True + + tag_class_model = None + + @classmethod + def valid_for(cls, taxonomy: Taxonomy = None, **kwars): + """ + Validates if has an associated Django model that has an Id + """ + if not super().valid_for(taxonomy=taxonomy) or not cls.tag_class_model: + return False + + # Verify that is a Django model + if not issubclass(cls.tag_class_model, models.Model): + return False + + # Verify that the model has 'id' field + try: + cls.tag_class_model._meta.get_field('id') + except FieldDoesNotExist: + return False + + return True + + def _check_instance(self): + """ + Validates if the instance exists + """ + try: + intance_id = int(self.value) + except ValueError: + return False + return self.tag_class_model.objects.filter(id=intance_id).exists() + + def _check_tag(self): + """ + Validates if the instance exists + """ + if not super()._check_tag(): + return False + + # Validates if the instance exists + if not self._check_instance(): + return False + + return True + + +class UserObjectTag(ModelObjectTag): + """ + Object tags used on taxonomies associated with user model + """ + + class Meta: + proxy = True + + tag_class_model = get_user_model() + + +class LanguageObjectTag(ClosedSystemObjectTag): + """ + Object tag for Languages + + The tags are filtered and validated taking into account the + languages available in Django LANGUAGES settings var + """ + + class Meta: + proxy = True + + @classmethod + def get_tags(cls, taxonomy: Taxonomy) -> List[Tag]: + """ + Returns a list of tags of the available languages. + """ + # TODO we need to overweite this + # tags = super().get_tags() + tags = taxonomy.tag_set.objects().all() + result = [] + available_langs = cls.get_available_languages() + for tag in tags: + if tag.external_id in available_langs: + result.append(tag) + return result + + @classmethod + def _get_available_languages(cls) -> List[str]: + """ + Get the available languages from Django LANGUAGE. + """ + langs = set() + for django_lang in settings.LANGUAGES: + # Split to get the language part + langs.add(django_lang[0].split('-')[0]) + return langs + + + def _check_tag(self): + """ + Validates if the language tag is on the available languages + """ + if not super()._check_tag(): + return False + + available_langs = self._get_available_languages() + + # Must be linked to a tag and must be an available language + if not self.tag or not self.tag.external_id in available_langs: + return False + + return True + + +# Register the object tag classes in reverse order for how we want them considered +register_object_tag_class(LanguageObjectTag) +register_object_tag_class(UserObjectTag) diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py b/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py deleted file mode 100644 index f25300c5..00000000 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/system_defined.py +++ /dev/null @@ -1,174 +0,0 @@ -from typing import List - -from django.conf import settings -from django.contrib.auth import get_user_model - -from openedx_tagging.core.tagging.models import ObjectTag, Tag, SystemTaxonomy, SystemDefinedTaxonomyTagsType - -class IdBasedSystemTaxonomyMixin: - """ - Mixin for ID based taxonomies. - - Used to build tags with a prefix. E.x user: - """ - - prefix = "" - separator = ":" - - @property - def creation_type(self) -> SystemDefinedTaxonomyTagsType: - return SystemDefinedTaxonomyTagsType.free_form - - def get_id(self, tag_value): - """ - Get the id part of the tag - """ - return tag_value.split(self.separator)[1] - - def build_tag(self, id): - """ - Build the tag with the prefix and the separator - """ - return f"{self.prefix}{self.separator}{id}" - - def tag_object( - self, tags: List, object_id: str, object_type: str - ) -> List["ObjectTag"]: - """ - Overwriten the tag_object of the Taxonomy model - - Preprocess and build the new tags - """ - new_tags = [] - for tag in tags: - new_tags.append(self.build_tag(tag)) - - return super().tag_object(tags, object_id, object_type) - - -class LanguageTaxonomy(SystemTaxonomy): - """ - Language system-defined taxonomy. - - Tag creation: Hardcoded by fixtures - Visible: True - Allow multiple: False - Tags: ISO 639-1 Languages - - The tags are filtered and validated taking into account the - languages available in Django LANGUAGES settings var - """ - - class Meta: - proxy = True - - @property - def is_visible(self) -> bool: - raise True - - @property - def creation_type(self) -> SystemDefinedTaxonomyTagsType: - return SystemDefinedTaxonomyTagsType.closed - - def get_available_languages(self) -> List[str]: - """ - Get the available languages from Django LANGUAGE. - """ - langs = set() - for django_lang in settings.LANGUAGES: - # Split to get the language part - langs.add(django_lang[0].split('-')[0]) - return langs - - def get_tags(self) -> List[Tag]: - """ - Returns a list of tags of the available languages. - """ - tags = super().get_tags() - result = [] - available_langs = self.get_available_languages() - for tag in tags: - if tag.external_id in available_langs: - result.append(tag) - return result - - def validate_object_tag( - self, - object_tag: "ObjectTag", - check_taxonomy=True, - check_tag=True, - check_object=True, - ) -> bool: - """ - Makes the normal object validation and validates if the - tag is an available language - """ - validation = super().validate_object_tag( - object_tag, - check_taxonomy, - check_tag, - check_object, - ) - if not validation: - return False - - available_langs = self.get_available_languages() - - # Must be linked to a tag and must be an available language - if not object_tag.tag or not object_tag.tag.external_id in available_langs: - return False - - return True - - -class AuthorTaxonomy(SystemTaxonomy, IdBasedSystemTaxonomyMixin): - """ - Author system-defined taxonomy - - Tag creation: Free form - Visible: False - Allow multiple: False - Tags: Id's of users in the form: author: - """ - - class Meta: - proxy = True - - prefix = "author" - - @property - def is_visible(self) -> bool: - raise False - - def validate_object_tag( - self, - object_tag: "ObjectTag", - check_taxonomy=True, - check_tag=True, - check_object=True, - ) -> bool: - """ - Makes the normal object validation and validates if the user exists - """ - validation = super().validate_object_tag( - object_tag, - check_taxonomy, - check_tag, - check_object, - ) - if not validation: - return False - - user_id = self.get_id(object_tag.value) - - # The user must exists - if not self._user_exists(user_id): - return False - - return True - - def _user_exists(self, user_id): - """ - Verify if an user exists - """ - return get_user_model().objects.filter(id=user_id).exists() diff --git a/tests/openedx_tagging/core/fixtures/system_defined.yaml b/tests/openedx_tagging/core/fixtures/system_defined.yaml new file mode 100644 index 00000000..3db3ec00 --- /dev/null +++ b/tests/openedx_tagging/core/fixtures/system_defined.yaml @@ -0,0 +1,10 @@ +- model: oel_tagging.taxonomy + pk: 1 + fields: + name: System Languages + description: Allows tags for any language configured for use on the instance. + enabled: true + required: false + allow_multiple: false + allow_free_text: false + system_defined: true \ No newline at end of file diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml index a56ece81..259d63d1 100644 --- a/tests/openedx_tagging/core/fixtures/tagging.yaml +++ b/tests/openedx_tagging/core/fixtures/tagging.yaml @@ -165,3 +165,13 @@ allow_multiple: false allow_free_text: false system_defined: true +- model: oel_tagging.taxonomy + pk: 3 + fields: + name: User Authors + description: Allows tags for any User on the instance. + enabled: true + required: false + allow_multiple: false + allow_free_text: true + system_defined: true \ No newline at end of file diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index 798b4826..b7d7ff7e 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -47,7 +47,12 @@ def test_get_taxonomies(self): tax2 = tagging_api.create_taxonomy("Disabled", enabled=False) with self.assertNumQueries(1): enabled = list(tagging_api.get_taxonomies()) - assert enabled == [tax1, self.taxonomy, self.system_taxonomy] + assert enabled == [ + tax1, + self.taxonomy, + self.system_taxonomy, + self.user_system_taxonomy + ] assert str(enabled[0]) == f" ({tax1.id}) Enabled" assert str(enabled[1]) == " (1) Life on Earth" assert str(enabled[2]) == " (2) System Languages" @@ -59,7 +64,13 @@ def test_get_taxonomies(self): with self.assertNumQueries(1): both = list(tagging_api.get_taxonomies(enabled=None)) - assert both == [tax2, tax1, self.taxonomy, self.system_taxonomy] + assert both == [ + tax2, + tax1, + self.taxonomy, + self.system_taxonomy, + self.user_system_taxonomy + ] def test_get_tags(self): self.setup_tag_depths() diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index 65af41ea..4e78a8f4 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -24,6 +24,7 @@ def setUp(self): super().setUp() self.taxonomy = Taxonomy.objects.get(name="Life on Earth") self.system_taxonomy = Taxonomy.objects.get(name="System Languages") + self.user_system_taxonomy = Taxonomy.objects.get(name="User Authors") self.archaea = get_tag("Archaea") self.archaebacteria = get_tag("Archaebacteria") self.bacteria = get_tag("Bacteria") diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index 94b93422..d0abf3bf 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -1,17 +1,20 @@ -""" Test the System-defined taxonomies """ - -import ddt +""" Test the System-defined taxonomies and object tags """ from django.contrib.auth import get_user_model +from django.db import models from django.test.testcases import TestCase, override_settings -from openedx_tagging.core.tagging.models import Taxonomy, ObjectTag, Tag -from openedx_tagging.core.tagging.system_defined_taxonomies.system_defined import ( - LanguageTaxonomy, - AuthorTaxonomy +from openedx_tagging.core.tagging.system_defined_taxonomies.object_tags import ( + OpenSystemObjectTag, + ClosedSystemObjectTag, + ModelObjectTag, + UserObjectTag, + LanguageObjectTag, ) +from openedx_tagging.core.tagging.models import Tag, Taxonomy + +from .test_models import TestTagTaxonomyMixin -fixtures_path = "openedx_tagging/core/tagging/system_defined_taxonomies/fixtures" test_languages = [ ('en', 'English'), ('az', 'Azerbaijani'), @@ -20,153 +23,206 @@ ('zu', 'Zulu'), ] -class TestSystemDefinedTaxonomyMixin: + +class EmptyTestClass: """ - Mixin used on system-defined taxonomy tests + Empty class used for testing """ - fixtures = [f"{fixtures_path}/taxonomies.yaml"] - def setUp(self): - super().setUp() - temp_taxonomy = Taxonomy.objects.get(pk=1) - self.language_taxonomy = LanguageTaxonomy() - self.language_taxonomy.__dict__ = temp_taxonomy.__dict__ - temp_taxonomy = Taxonomy.objects.get(pk=2) - self.author_taxonomy = AuthorTaxonomy() - self.author_taxonomy.__dict__ = temp_taxonomy.__dict__ +class EmptyTestModel(models.Model): + """ + Model used for testing + """ + mi_id = models.AutoField(primary_key=True) + class Meta: + managed = False + app_label = 'oel_tagging' -@ddt.ddt -@override_settings(LANGUAGES=test_languages) -class TestLanguageTaxonomy(TestSystemDefinedTaxonomyMixin, TestCase): + +class EmptyObjectTag(ModelObjectTag): """ - Test the Language Taxonomy + Model Object tag used for testing """ + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' - def setUp(self): - super().setUp() - self.expected_langs_ids = sorted([item[0] for item in test_languages]) - self.expected_langs_values = sorted([item[1] for item in test_languages]) - self.english_tag = Tag.objects.get(value="English") - self.spanish_tag = Tag.objects.get(value="Spanish") + tag_class_model = EmptyTestClass - fixtures = ( - TestSystemDefinedTaxonomyMixin.fixtures - + [f"{fixtures_path}/language_taxonomy.yaml"] - ) - def test_fixture(self): - self.assertEqual(self.language_taxonomy.name, 'Language') - for lang_code, lang_value in test_languages: - lang = self.language_taxonomy.tag_set.get(external_id=lang_code) - self.assertEqual(lang.value, lang_value) +class EmptyModelObjectTag(ModelObjectTag): + """ + Model Object tag used for testing + """ + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' - def test_get_available_languages(self): - langs = self.language_taxonomy.get_available_languages() - self.assertEqual(sorted(langs), self.expected_langs_ids) + tag_class_model = EmptyTestModel - def test_get_tags(self): - tags = self.language_taxonomy.get_tags() - for tag in tags: - self.assertIn(tag.value, self.expected_langs_values) - self.assertEqual(tag.annotated_field, 0) # Checking depth - - def test_validate_object_tag(self): - object_id = 'object_id' - invalid_object_tag_1 = ObjectTag( - taxonomy=self.language_taxonomy, - object_id=object_id, - tag=self.spanish_tag, + +class TestSystemDefinedObjectTags(TestTagTaxonomyMixin, TestCase): + """ + Test for generic system defined object tags + """ + def test_open_valid_for(self): + #Valid + assert OpenSystemObjectTag.valid_for(taxonomy=self.user_system_taxonomy) + + # Not open system taxonomy + assert not OpenSystemObjectTag.valid_for(taxonomy=self.system_taxonomy) + + # Not system taxonomy + assert not OpenSystemObjectTag.valid_for(taxonomy=self.taxonomy) + + def test_closed_valid_for(self): + #Valid + assert ClosedSystemObjectTag.valid_for(taxonomy=self.system_taxonomy, tag=self.archaea) + + # Not closed system taxonomy + assert not ClosedSystemObjectTag.valid_for(taxonomy=self.user_system_taxonomy, tag=self.archaea) + + # Not system taxonomy + assert not ClosedSystemObjectTag.valid_for(taxonomy=self.taxonomy, tag=self.archaea) + + def test_model_valid_for(self): + # Without associated model + assert not ModelObjectTag.valid_for(self.user_system_taxonomy) + + # Associated class is not a Django model + assert not EmptyObjectTag.valid_for(self.user_system_taxonomy) + + # Associated model has not 'id' field + assert not EmptyModelObjectTag.valid_for(self.user_system_taxonomy) + + #Valid + assert UserObjectTag.valid_for(self.user_system_taxonomy) + + def test_model_is_valid(self): + user = get_user_model()( + username='username', + email='email' ) - invalid_object_tag_2 = ObjectTag( - object_id=object_id, + user.save() + valid_object_tag = UserObjectTag( + taxonomy=self.user_system_taxonomy, + object_id='id 1', + object_type='object', + value=user.id, ) - valid_object_tag = ObjectTag( - taxonomy=self.language_taxonomy, - object_id=object_id, - tag=self.english_tag, + invalid_object_tag_1 = UserObjectTag( + taxonomy=self.user_system_taxonomy, + object_id='id 2', + object_type='object', + value='user_id', + ) + invalid_object_tag_2 = UserObjectTag( + taxonomy=self.user_system_taxonomy, + object_id='id 2', + object_type='object', + value='10000', + ) + invalid_object_tag_3 = UserObjectTag( + taxonomy=self.user_system_taxonomy, + object_id='id 3', + object_type='object', ) - # Invalid. Language is not available - self.language_taxonomy.validate_object_tag( - object_tag=invalid_object_tag_1, - check_taxonomy=False, - check_tag=False, - check_object=False, + # Invalid user id + assert not invalid_object_tag_1.is_valid( + check_taxonomy=True, + check_object=True, + check_tag=True, ) - # Invalid. Testing normal object validations - self.language_taxonomy.validate_object_tag( - object_tag=invalid_object_tag_2, + # User don't exits + assert not invalid_object_tag_2.is_valid( check_taxonomy=True, - check_tag=False, - check_object=False, + check_object=True, + check_tag=True, + ) + + # Testing parent class validations + assert not invalid_object_tag_3.is_valid( + check_taxonomy=True, + check_object=True, + check_tag=True, ) # Valid - self.language_taxonomy.validate_object_tag( - object_tag=valid_object_tag, - check_taxonomy=False, - check_tag=False, - check_object=False, + assert valid_object_tag.is_valid( + check_taxonomy=True, + check_object=True, + check_tag=True, ) -class TestAuthorTaxonomy(TestSystemDefinedTaxonomyMixin, TestCase): +@override_settings(LANGUAGES=test_languages) +class TestLanguageObjectClass(TestCase): """ - Test the Author Taxonomy + Test for Language object class """ + fixtures = [ + "tests/openedx_tagging/core/fixtures/system_defined.yaml", + "openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml" + ] + def setUp(self): super().setUp() - self.user = get_user_model()( - username='test', - email='test@test.com', + self.taxonomy = Taxonomy.objects.get(name="System Languages") + self.expected_langs_ids = sorted([item[0] for item in test_languages]) + self.expected_langs_values = sorted([item[1] for item in test_languages]) + self.english_tag = Tag.objects.get(value="English") + self.spanish_tag = Tag.objects.get(value="Spanish") + + def test_get_available_languages(self): + langs = LanguageObjectTag._get_available_languages() + self.assertEqual(sorted(langs), self.expected_langs_ids) + + + def test_is_valid(self): + valid_object_tag = LanguageObjectTag( + taxonomy=self.taxonomy, + object_id='id 1', + object_type='object', + tag=self.english_tag, ) - self.user.save() - - def test_user_exists(self): - self.assertFalse(self.author_taxonomy._user_exists(100)) - self.assertTrue(self.author_taxonomy._user_exists(self.user.id)) - - def test_validate_object_tag(self): - object_id = 'object_id' - invalid_object_tag_1 = ObjectTag( - taxonomy=self.author_taxonomy, - object_id=object_id, - _value="author:100" + + invalid_onbject_tag_1 = LanguageObjectTag( + taxonomy=self.taxonomy, + object_id='id 2', + object_type='object', + tag=self.spanish_tag, ) - invalid_object_tag_2 = ObjectTag( - object_id=object_id, - _value="author:100" + + invalid_onbject_tag_2 = LanguageObjectTag( + taxonomy=self.taxonomy, + object_id='id 2', + object_type='object', ) - valid_object_tag = ObjectTag( - taxonomy=self.author_taxonomy, - object_id=object_id, - _value=f"author:{self.user.id}" + + # Tag is not in available languages + assert not invalid_onbject_tag_1.is_valid( + check_taxonomy=True, + check_object=True, + check_tag=True, ) - # Invalid. User doesn't exist - self.assertFalse(self.author_taxonomy.validate_object_tag( - object_tag=invalid_object_tag_1, - check_taxonomy=False, - check_tag=False, - check_object=False, - )) - - # Invalid. Testing normal object validations - self.assertFalse(self.author_taxonomy.validate_object_tag( - object_tag=invalid_object_tag_2, + # Testing parent class validations + assert not invalid_onbject_tag_2.is_valid( check_taxonomy=True, - check_tag=False, - check_object=False, - )) + check_object=True, + check_tag=True, + ) # Valid - self.assertTrue(self.author_taxonomy.validate_object_tag( - object_tag=valid_object_tag, - check_taxonomy=False, - check_tag=False, - check_object=False, - )) + assert valid_object_tag.is_valid( + check_taxonomy=True, + check_object=True, + check_tag=True, + ) From 58fc1e8cbaad3522b7d71cc9a2c9bd1b67f8c94c Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 10 Jul 2023 14:42:17 -0500 Subject: [PATCH 06/20] chore: get_tags_query_set added to LanguageObjectTag --- .../system_defined_taxonomies/object_tags.py | 20 ++++++------------- .../core/tagging/test_system_defined.py | 6 +++++- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py index d4cc5079..783d60ce 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py @@ -125,19 +125,12 @@ class Meta: proxy = True @classmethod - def get_tags(cls, taxonomy: Taxonomy) -> List[Tag]: - """ - Returns a list of tags of the available languages. - """ - # TODO we need to overweite this - # tags = super().get_tags() - tags = taxonomy.tag_set.objects().all() - result = [] - available_langs = cls.get_available_languages() - for tag in tags: - if tag.external_id in available_langs: - result.append(tag) - return result + def get_tags_query_set(cls, taxonomy: Taxonomy) -> models.QuerySet: + """ + Returns a query set of available languages tags. + """ + available_langs = cls._get_available_languages() + return taxonomy.tag_set.filter(external_id__in=available_langs) @classmethod def _get_available_languages(cls) -> List[str]: @@ -150,7 +143,6 @@ def _get_available_languages(cls) -> List[str]: langs.add(django_lang[0].split('-')[0]) return langs - def _check_tag(self): """ Validates if the language tag is on the available languages diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index d0abf3bf..a3aa53c6 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -184,7 +184,6 @@ def test_get_available_languages(self): langs = LanguageObjectTag._get_available_languages() self.assertEqual(sorted(langs), self.expected_langs_ids) - def test_is_valid(self): valid_object_tag = LanguageObjectTag( taxonomy=self.taxonomy, @@ -226,3 +225,8 @@ def test_is_valid(self): check_object=True, check_tag=True, ) + + def test_get_tags_query_set(self): + tags = LanguageObjectTag.get_tags_query_set(self.taxonomy) + for tag in tags: + self.assertIn(tag.value, self.expected_langs_values) From 9e7ed4e5e127a8008c8c0f4f72abf942183b89bf Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 10 Jul 2023 16:26:35 -0500 Subject: [PATCH 07/20] chore: Adding _validate_taxonomy function to all system defined object tags --- .../system_defined_taxonomies/object_tags.py | 46 ++++++++++++-- .../core/tagging/test_system_defined.py | 63 ++++++++++++++++--- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py index 783d60ce..b80677bd 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py @@ -15,7 +15,41 @@ from openedx_tagging.core.tagging.registry import register_object_tag_class -class OpenSystemObjectTag(OpenObjectTag): +class SystemDefinedObjectTagMixin: + """ + Mixing for ObjectTags used on all system defined taxonomies + + `system_defined_taxonomy_name``is used to connect the + ObjectTag with the system defined taxonomy. + This is because there can be several ObjectTags + for the same Taxonomy, ex: + + - LanguageCourseObjectTag + - LanguageBlockObjectTag + + On the example, there are ObjectTags for the same Language + taxonomy but with different objects. + + Using this approach makes the connection between the ObjectTag + and system defined taxonomy as hardcoded. + """ + + system_defined_taxonomy_name = None + + @classmethod + def _validate_taxonomy(cls, taxonomy: Taxonomy = None): + """ + Validates if the taxonomy is system-defined and match + with the name stored in the object tag + """ + return ( + bool(taxonomy) and + taxonomy.system_defined and + taxonomy.name == cls.system_defined_taxonomy_name + ) + + +class OpenSystemObjectTag(OpenObjectTag, SystemDefinedObjectTagMixin): """ Free-text object tag used on system-defined taxonomies """ @@ -28,9 +62,10 @@ def valid_for(cls, taxonomy: Taxonomy = None, **kwars): """ Returns True if the the taxonomy is system-defined """ - return super().valid_for(taxonomy=taxonomy) and taxonomy.system_defined + return super().valid_for(taxonomy=taxonomy) and cls._validate_taxonomy(taxonomy) + -class ClosedSystemObjectTag(ClosedObjectTag): +class ClosedSystemObjectTag(ClosedObjectTag, SystemDefinedObjectTagMixin): """ Object tags linked to a closed system-taxonomy """ @@ -43,7 +78,7 @@ def valid_for(cls, taxonomy: Taxonomy = None, tag: Tag = None, **kargs): """ Returns True if the the taxonomy is system-defined """ - return super().valid_for(taxonomy=taxonomy, tag=tag) and taxonomy.system_defined + return super().valid_for(taxonomy=taxonomy, tag=tag) and cls._validate_taxonomy(taxonomy) class ModelObjectTag(OpenSystemObjectTag): @@ -121,6 +156,8 @@ class LanguageObjectTag(ClosedSystemObjectTag): languages available in Django LANGUAGES settings var """ + system_defined_taxonomy_name = "System Languages" + class Meta: proxy = True @@ -161,4 +198,3 @@ def _check_tag(self): # Register the object tag classes in reverse order for how we want them considered register_object_tag_class(LanguageObjectTag) -register_object_tag_class(UserObjectTag) diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index a3aa53c6..2f84ac07 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -43,8 +43,11 @@ class Meta: class EmptyObjectTag(ModelObjectTag): """ - Model Object tag used for testing + Model ObjectTag used for testing """ + + system_defined_taxonomy_name = "User Authors" + class Meta: proxy = True managed = False @@ -55,8 +58,11 @@ class Meta: class EmptyModelObjectTag(ModelObjectTag): """ - Model Object tag used for testing + Model ObjectTag used for testing """ + + system_defined_taxonomy_name = "User Authors" + class Meta: proxy = True managed = False @@ -65,29 +71,68 @@ class Meta: tag_class_model = EmptyTestModel +class TestOpenObjectTag(OpenSystemObjectTag): + """ + Open ObjectTag used for testing + """ + + system_defined_taxonomy_name = "User Authors" + + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' + + +class TestClosedObjectTag(ClosedSystemObjectTag): + """ + Closed ObjectTag used for testing + """ + + system_defined_taxonomy_name = "System Languages" + + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' + + +class TestUserObjectTag(UserObjectTag): + """ + User ObjectTag used for testing + """ + + system_defined_taxonomy_name = "User Authors" + + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' + + class TestSystemDefinedObjectTags(TestTagTaxonomyMixin, TestCase): """ Test for generic system defined object tags """ def test_open_valid_for(self): #Valid - assert OpenSystemObjectTag.valid_for(taxonomy=self.user_system_taxonomy) + assert TestOpenObjectTag.valid_for(taxonomy=self.user_system_taxonomy) # Not open system taxonomy - assert not OpenSystemObjectTag.valid_for(taxonomy=self.system_taxonomy) + assert not TestOpenObjectTag.valid_for(taxonomy=self.system_taxonomy) # Not system taxonomy - assert not OpenSystemObjectTag.valid_for(taxonomy=self.taxonomy) + assert not TestOpenObjectTag.valid_for(taxonomy=self.taxonomy) def test_closed_valid_for(self): #Valid - assert ClosedSystemObjectTag.valid_for(taxonomy=self.system_taxonomy, tag=self.archaea) + assert TestClosedObjectTag.valid_for(taxonomy=self.system_taxonomy, tag=self.archaea) # Not closed system taxonomy - assert not ClosedSystemObjectTag.valid_for(taxonomy=self.user_system_taxonomy, tag=self.archaea) + assert not TestClosedObjectTag.valid_for(taxonomy=self.user_system_taxonomy, tag=self.archaea) # Not system taxonomy - assert not ClosedSystemObjectTag.valid_for(taxonomy=self.taxonomy, tag=self.archaea) + assert not TestClosedObjectTag.valid_for(taxonomy=self.taxonomy, tag=self.archaea) def test_model_valid_for(self): # Without associated model @@ -100,7 +145,7 @@ def test_model_valid_for(self): assert not EmptyModelObjectTag.valid_for(self.user_system_taxonomy) #Valid - assert UserObjectTag.valid_for(self.user_system_taxonomy) + assert TestUserObjectTag.valid_for(self.user_system_taxonomy) def test_model_is_valid(self): user = get_user_model()( From 899d61f0bdd30e589715052afec8721e0bb8c7ce Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 11 Jul 2023 18:19:59 -0500 Subject: [PATCH 08/20] chore: Updating system_defined_taxonomy_id --- .../fixtures/language_taxonomy.yaml | 0 .../core/tagging/fixtures/system_defined.yaml | 9 +++++++++ .../fixtures/taxonomies.yaml | 18 ------------------ .../system_defined_taxonomies/object_tags.py | 19 +++++++++++++++---- .../core/tagging/test_system_defined.py | 12 ++++++------ 5 files changed, 30 insertions(+), 28 deletions(-) rename openedx_tagging/core/tagging/{system_defined_taxonomies => }/fixtures/language_taxonomy.yaml (100%) create mode 100644 openedx_tagging/core/tagging/fixtures/system_defined.yaml delete mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml similarity index 100% rename from openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml rename to openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml diff --git a/openedx_tagging/core/tagging/fixtures/system_defined.yaml b/openedx_tagging/core/tagging/fixtures/system_defined.yaml new file mode 100644 index 00000000..727d625d --- /dev/null +++ b/openedx_tagging/core/tagging/fixtures/system_defined.yaml @@ -0,0 +1,9 @@ +- model: oel_tagging.taxonomy + pk: 1 + fields: + name: Languages + description: ISO 639-1 Languages. Allows tags for any language configured for use on the instance + enabled: true + required: true + allow_multiple: false + allow_free_text: false diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml b/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml deleted file mode 100644 index 12a2b43b..00000000 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/taxonomies.yaml +++ /dev/null @@ -1,18 +0,0 @@ -- model: oel_tagging.taxonomy - pk: 1 - fields: - name: Languages - description: ISO 639-1 Languages - enabled: true - required: true - allow_multiple: false - allow_free_text: false -- model: oel_tagging.taxonomy - pk: 2 - fields: - name: Author - description: User Ids of the platform - enabled: true - required: true - allow_multiple: false - allow_free_text: true diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py index b80677bd..191eb3f9 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py @@ -1,3 +1,7 @@ +""" +ObjectTags for System-defined Taxonomies +""" +from enum import Enum from typing import List from django.conf import settings @@ -15,11 +19,18 @@ from openedx_tagging.core.tagging.registry import register_object_tag_class +class SystemDefinedIds(Enum): + """ + System-defined taxonomy IDs + """ + LanguageTaxonomy = 1 + + class SystemDefinedObjectTagMixin: """ Mixing for ObjectTags used on all system defined taxonomies - `system_defined_taxonomy_name``is used to connect the + `system_defined_taxonomy_id``is used to connect the ObjectTag with the system defined taxonomy. This is because there can be several ObjectTags for the same Taxonomy, ex: @@ -34,7 +45,7 @@ class SystemDefinedObjectTagMixin: and system defined taxonomy as hardcoded. """ - system_defined_taxonomy_name = None + system_defined_taxonomy_id = None @classmethod def _validate_taxonomy(cls, taxonomy: Taxonomy = None): @@ -45,7 +56,7 @@ def _validate_taxonomy(cls, taxonomy: Taxonomy = None): return ( bool(taxonomy) and taxonomy.system_defined and - taxonomy.name == cls.system_defined_taxonomy_name + taxonomy.id == cls.system_defined_taxonomy_id ) @@ -156,7 +167,7 @@ class LanguageObjectTag(ClosedSystemObjectTag): languages available in Django LANGUAGES settings var """ - system_defined_taxonomy_name = "System Languages" + system_defined_taxonomy_id = SystemDefinedIds.LanguageTaxonomy class Meta: proxy = True diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index 2f84ac07..2996596e 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -46,7 +46,7 @@ class EmptyObjectTag(ModelObjectTag): Model ObjectTag used for testing """ - system_defined_taxonomy_name = "User Authors" + system_defined_taxonomy_id = 3 class Meta: proxy = True @@ -61,7 +61,7 @@ class EmptyModelObjectTag(ModelObjectTag): Model ObjectTag used for testing """ - system_defined_taxonomy_name = "User Authors" + system_defined_taxonomy_id = 3 class Meta: proxy = True @@ -76,7 +76,7 @@ class TestOpenObjectTag(OpenSystemObjectTag): Open ObjectTag used for testing """ - system_defined_taxonomy_name = "User Authors" + system_defined_taxonomy_id = 3 class Meta: proxy = True @@ -89,7 +89,7 @@ class TestClosedObjectTag(ClosedSystemObjectTag): Closed ObjectTag used for testing """ - system_defined_taxonomy_name = "System Languages" + system_defined_taxonomy_id = 2 class Meta: proxy = True @@ -102,7 +102,7 @@ class TestUserObjectTag(UserObjectTag): User ObjectTag used for testing """ - system_defined_taxonomy_name = "User Authors" + system_defined_taxonomy_id = 3 class Meta: proxy = True @@ -214,7 +214,7 @@ class TestLanguageObjectClass(TestCase): fixtures = [ "tests/openedx_tagging/core/fixtures/system_defined.yaml", - "openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml" + "openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml" ] def setUp(self): From 1e68687be4c2100f5b64f3a5bcc4bcea7c740571 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 12 Jul 2023 13:57:06 -0500 Subject: [PATCH 09/20] refactor: consolidates ObjectTag validation --- MANIFEST.in | 2 +- .../core/tagging/fixtures/system_defined.yaml | 2 + .../commands/build_language_fixture.py | 2 +- openedx_tagging/core/tagging/models.py | 3 - .../system_defined_taxonomies/object_tags.py | 51 +++--- .../core/fixtures/system_defined.yaml | 2 +- .../core/fixtures/tagging.yaml | 2 +- .../core/tagging/test_system_defined.py | 163 +++++++----------- 8 files changed, 92 insertions(+), 135 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 8cbdc3a6..0a2abd08 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,4 @@ include LICENSE.txt include README.rst include requirements/base.in recursive-include openedx_learning *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py -recursive-include openedx_tagging *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py +recursive-include openedx_tagging *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py *.yaml diff --git a/openedx_tagging/core/tagging/fixtures/system_defined.yaml b/openedx_tagging/core/tagging/fixtures/system_defined.yaml index 727d625d..c5737b64 100644 --- a/openedx_tagging/core/tagging/fixtures/system_defined.yaml +++ b/openedx_tagging/core/tagging/fixtures/system_defined.yaml @@ -7,3 +7,5 @@ required: true allow_multiple: false allow_free_text: false + system_defined: true + visible_to_authors: true diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py index fef118a5..0db72495 100644 --- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json" -output = "./openedx_tagging/core/tagging/system_defined_taxonomies/fixtures/language_taxonomy.yaml" +output = "./openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml" class Command(BaseCommand): diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index add3ac33..3bd42bfa 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -637,9 +637,6 @@ def _check_object(self): """ # Must have a valid object id/type: return self.object_id and self.object_type - - def _check_tag(self): - return self.value class ClosedObjectTag(OpenObjectTag): diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py index 191eb3f9..a54bf05c 100644 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py +++ b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py @@ -11,7 +11,6 @@ from openedx_tagging.core.tagging.models import ( - Tag, Taxonomy, OpenObjectTag, ClosedObjectTag, @@ -42,21 +41,20 @@ class SystemDefinedObjectTagMixin: taxonomy but with different objects. Using this approach makes the connection between the ObjectTag - and system defined taxonomy as hardcoded. + and system defined taxonomy as hardcoded and can't be changed. """ system_defined_taxonomy_id = None - @classmethod - def _validate_taxonomy(cls, taxonomy: Taxonomy = None): + def _check_system_taxonomy(self, taxonomy: Taxonomy = None): """ Validates if the taxonomy is system-defined and match with the name stored in the object tag """ return ( - bool(taxonomy) and - taxonomy.system_defined and - taxonomy.id == cls.system_defined_taxonomy_id + bool(taxonomy) and + taxonomy.system_defined and + taxonomy.id == self.system_defined_taxonomy_id ) @@ -68,12 +66,11 @@ class OpenSystemObjectTag(OpenObjectTag, SystemDefinedObjectTagMixin): class Meta: proxy = True - @classmethod - def valid_for(cls, taxonomy: Taxonomy = None, **kwars): - """ - Returns True if the the taxonomy is system-defined - """ - return super().valid_for(taxonomy=taxonomy) and cls._validate_taxonomy(taxonomy) + def _check_taxonomy(self): + return ( + super()._check_taxonomy() and + self._check_system_taxonomy(self.taxonomy) + ) class ClosedSystemObjectTag(ClosedObjectTag, SystemDefinedObjectTagMixin): @@ -84,12 +81,11 @@ class ClosedSystemObjectTag(ClosedObjectTag, SystemDefinedObjectTagMixin): class Meta: proxy = True - @classmethod - def valid_for(cls, taxonomy: Taxonomy = None, tag: Tag = None, **kargs): - """ - Returns True if the the taxonomy is system-defined - """ - return super().valid_for(taxonomy=taxonomy, tag=tag) and cls._validate_taxonomy(taxonomy) + def _check_taxonomy(self): + return ( + super()._check_taxonomy() and + self._check_system_taxonomy(self.taxonomy) + ) class ModelObjectTag(OpenSystemObjectTag): @@ -104,21 +100,24 @@ class Meta: tag_class_model = None - @classmethod - def valid_for(cls, taxonomy: Taxonomy = None, **kwars): + + def _check_taxonomy(self): """ Validates if has an associated Django model that has an Id """ - if not super().valid_for(taxonomy=taxonomy) or not cls.tag_class_model: + if not super()._check_taxonomy(): return False - + + if not self.tag_class_model: + return False + # Verify that is a Django model - if not issubclass(cls.tag_class_model, models.Model): + if not issubclass(self.tag_class_model, models.Model): return False # Verify that the model has 'id' field try: - cls.tag_class_model._meta.get_field('id') + self.tag_class_model._meta.get_field('id') except FieldDoesNotExist: return False @@ -167,7 +166,7 @@ class LanguageObjectTag(ClosedSystemObjectTag): languages available in Django LANGUAGES settings var """ - system_defined_taxonomy_id = SystemDefinedIds.LanguageTaxonomy + system_defined_taxonomy_id = SystemDefinedIds.LanguageTaxonomy.value class Meta: proxy = True diff --git a/tests/openedx_tagging/core/fixtures/system_defined.yaml b/tests/openedx_tagging/core/fixtures/system_defined.yaml index 3db3ec00..9fade190 100644 --- a/tests/openedx_tagging/core/fixtures/system_defined.yaml +++ b/tests/openedx_tagging/core/fixtures/system_defined.yaml @@ -7,4 +7,4 @@ required: false allow_multiple: false allow_free_text: false - system_defined: true \ No newline at end of file + system_defined: true diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml index 259d63d1..4174fb4f 100644 --- a/tests/openedx_tagging/core/fixtures/tagging.yaml +++ b/tests/openedx_tagging/core/fixtures/tagging.yaml @@ -174,4 +174,4 @@ required: false allow_multiple: false allow_free_text: true - system_defined: true \ No newline at end of file + system_defined: true diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py index 2996596e..9de7fb3c 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined.py @@ -1,12 +1,12 @@ """ Test the System-defined taxonomies and object tags """ +import ddt + from django.contrib.auth import get_user_model from django.db import models from django.test.testcases import TestCase, override_settings from openedx_tagging.core.tagging.system_defined_taxonomies.object_tags import ( - OpenSystemObjectTag, - ClosedSystemObjectTag, ModelObjectTag, UserObjectTag, LanguageObjectTag, @@ -41,7 +41,7 @@ class Meta: app_label = 'oel_tagging' -class EmptyObjectTag(ModelObjectTag): +class EmptyModelObjectTag(ModelObjectTag): """ Model ObjectTag used for testing """ @@ -53,10 +53,8 @@ class Meta: managed = False app_label = 'oel_tagging' - tag_class_model = EmptyTestClass - -class EmptyModelObjectTag(ModelObjectTag): +class NotDjangoModelObjectTag(ModelObjectTag): """ Model ObjectTag used for testing """ @@ -68,12 +66,12 @@ class Meta: managed = False app_label = 'oel_tagging' - tag_class_model = EmptyTestModel + tag_class_model = EmptyTestClass -class TestOpenObjectTag(OpenSystemObjectTag): +class NotIdModelObjectTag(ModelObjectTag): """ - Open ObjectTag used for testing + Model ObjectTag used for testing """ system_defined_taxonomy_id = 3 @@ -83,18 +81,7 @@ class Meta: managed = False app_label = 'oel_tagging' - -class TestClosedObjectTag(ClosedSystemObjectTag): - """ - Closed ObjectTag used for testing - """ - - system_defined_taxonomy_id = 2 - - class Meta: - proxy = True - managed = False - app_label = 'oel_tagging' + tag_class_model = EmptyTestModel class TestUserObjectTag(UserObjectTag): @@ -110,100 +97,72 @@ class Meta: app_label = 'oel_tagging' +@ddt.ddt class TestSystemDefinedObjectTags(TestTagTaxonomyMixin, TestCase): """ Test for generic system defined object tags """ - def test_open_valid_for(self): - #Valid - assert TestOpenObjectTag.valid_for(taxonomy=self.user_system_taxonomy) - - # Not open system taxonomy - assert not TestOpenObjectTag.valid_for(taxonomy=self.system_taxonomy) - - # Not system taxonomy - assert not TestOpenObjectTag.valid_for(taxonomy=self.taxonomy) - - def test_closed_valid_for(self): - #Valid - assert TestClosedObjectTag.valid_for(taxonomy=self.system_taxonomy, tag=self.archaea) - - # Not closed system taxonomy - assert not TestClosedObjectTag.valid_for(taxonomy=self.user_system_taxonomy, tag=self.archaea) - - # Not system taxonomy - assert not TestClosedObjectTag.valid_for(taxonomy=self.taxonomy, tag=self.archaea) - - def test_model_valid_for(self): - # Without associated model - assert not ModelObjectTag.valid_for(self.user_system_taxonomy) - - # Associated class is not a Django model - assert not EmptyObjectTag.valid_for(self.user_system_taxonomy) - - # Associated model has not 'id' field - assert not EmptyModelObjectTag.valid_for(self.user_system_taxonomy) - - #Valid - assert TestUserObjectTag.valid_for(self.user_system_taxonomy) - - def test_model_is_valid(self): - user = get_user_model()( - username='username', - email='email' - ) - user.save() - valid_object_tag = UserObjectTag( - taxonomy=self.user_system_taxonomy, - object_id='id 1', - object_type='object', - value=user.id, - ) - invalid_object_tag_1 = UserObjectTag( - taxonomy=self.user_system_taxonomy, - object_id='id 2', - object_type='object', - value='user_id', - ) - invalid_object_tag_2 = UserObjectTag( - taxonomy=self.user_system_taxonomy, - object_id='id 2', - object_type='object', - value='10000', - ) - invalid_object_tag_3 = UserObjectTag( + def test_system_defined_is_valid(self): + # Valid + assert TestUserObjectTag()._check_system_taxonomy(taxonomy=self.user_system_taxonomy) + + # Null taxonomy + assert not UserObjectTag()._check_system_taxonomy() + + # Not system defined taxonomy + assert not UserObjectTag()._check_system_taxonomy(taxonomy=self.taxonomy) + + # Not connected with the taxonomy + assert not UserObjectTag()._check_system_taxonomy(taxonomy=self.user_system_taxonomy) + + @ddt.data( + (EmptyModelObjectTag, False), # Without associated model + (NotDjangoModelObjectTag, False), # Associated class is not a Django model + (NotIdModelObjectTag, False), # Associated model has not 'id' field + (ModelObjectTag, False), # Testing parent class validations + (TestUserObjectTag, True), #Valid + ) + @ddt.unpack + def test_model_object_is_valid(self, object_tag, assert_value): + args = { + 'taxonomy': self.user_system_taxonomy, + 'object_id': 'id', + 'object_type': 'object', + 'value': 'value', + } + result = object_tag(**args).is_valid(check_object=False, check_tag=False, check_taxonomy=True) + self.assertEqual(result, assert_value) + + @ddt.data( + (None, True), # Valid + ('user_id', False), # Invalid user id + ('10000', False), # User don't exits + (None, False), # Testing parent class validations + ) + @ddt.unpack + def test_user_object_is_valid(self, value, assert_value): + if assert_value: + user = get_user_model()( + username='username', + email='email' + ) + user.save() + value = user.id + + object_tag = TestUserObjectTag( taxonomy=self.user_system_taxonomy, - object_id='id 3', + object_id='id', object_type='object', + value=value, ) - # Invalid user id - assert not invalid_object_tag_1.is_valid( + result = object_tag.is_valid( check_taxonomy=True, check_object=True, check_tag=True, ) - # User don't exits - assert not invalid_object_tag_2.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - # Testing parent class validations - assert not invalid_object_tag_3.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - # Valid - assert valid_object_tag.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) + self.assertEqual(result, assert_value) @override_settings(LANGUAGES=test_languages) From 9e5d854cedbb15b13e9b0c949c321df6e30012c9 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 19 Jul 2023 18:29:32 -0500 Subject: [PATCH 10/20] feat: System defined taxonomies subclasses --- .../tagging/migrations/0003_system_defined.py | 57 ++++ openedx_tagging/core/tagging/models.py | 294 +++++++++++++++--- .../system_defined_taxonomies/object_tags.py | 210 ------------- .../core/fixtures/system_defined.yaml | 10 - .../core/fixtures/tagging.yaml | 22 +- .../openedx_tagging/core/tagging/test_api.py | 9 +- .../core/tagging/test_models.py | 263 +++++++++++++++- .../core/tagging/test_system_defined.py | 236 -------------- 8 files changed, 584 insertions(+), 517 deletions(-) create mode 100644 openedx_tagging/core/tagging/migrations/0003_system_defined.py delete mode 100644 openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py delete mode 100644 tests/openedx_tagging/core/fixtures/system_defined.yaml delete mode 100644 tests/openedx_tagging/core/tagging/test_system_defined.py diff --git a/openedx_tagging/core/tagging/migrations/0003_system_defined.py b/openedx_tagging/core/tagging/migrations/0003_system_defined.py new file mode 100644 index 00000000..d4c09425 --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0003_system_defined.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.19 on 2023-07-19 20:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0002_auto_20230718_2026'), + ] + + operations = [ + migrations.CreateModel( + name='SystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.taxonomy',), + ), + migrations.CreateModel( + name='LanguageTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.systemdefinedtaxonomy',), + ), + migrations.CreateModel( + name='ModelSystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.systemdefinedtaxonomy',), + ), + migrations.CreateModel( + name='UserSystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.modelsystemdefinedtaxonomy',), + ), + ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index 3bd42bfa..eccf40f2 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -1,8 +1,9 @@ """ Tagging app data models """ import logging -from typing import List, Type, Union -from enum import Enum +from typing import Any, List, Type, Union +from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ @@ -244,6 +245,12 @@ def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": self.visible_to_authors = taxonomy.visible_to_authors self._taxonomy_class = taxonomy._taxonomy_class return self + + def _get_tags_query_set(self) -> models.QuerySet: + """ + Base query set for used on `get_tags()` + """ + return self.tag_set def get_tags(self) -> List[Tag]: """ @@ -259,7 +266,7 @@ def get_tags(self) -> List[Tag]: parents = None for depth in range(TAXONOMY_MAX_DEPTH): - filtered_tags = self.tag_set.prefetch_related("parent") + filtered_tags = self._get_tags_query_set().prefetch_related("parent") if parents is None: filtered_tags = filtered_tags.filter(parent=None) else: @@ -346,6 +353,28 @@ def _check_object( """ return bool(object_tag.object_id) + def _set_tag( + self, + tag_ref, + object_tag: "ObjectTag", + ) -> "ObjectTag": + """ + Set a tag on `object_tag` and run a resync + + Subclasses can override this method to perform their own set tag process. + """ + try: + object_tag.tag = self.tag_set.get( + id=tag_ref, + ) + except (ValueError, Tag.DoesNotExist): + # This might be ok, e.g. if self.allow_free_text. + # We'll validate below before saving. + object_tag.value = tag_ref + + object_tag.resync() + return object_tag + def tag_object( self, tags: List, @@ -384,17 +413,9 @@ def tag_object( object_id=object_id, ) - try: - object_tag.tag = self.tag_set.get( - id=tag_ref, - ) - except (ValueError, Tag.DoesNotExist): - # This might be ok, e.g. if self.allow_free_text. - # We'll validate below before saving. - object_tag.value = tag_ref - - object_tag.resync() - if not self.validate_object_tag(object_tag): + object_tag = self._set_tag(tag_ref, object_tag) + + if not object_tag or not self.validate_object_tag(object_tag): raise ValueError( _(f"Invalid object tag for taxonomy ({self.id}): {tag_ref}") ) @@ -629,66 +650,239 @@ def copy(self, object_tag: "ObjectTag") -> "ObjectTag": self._name = object_tag._name return self - def _check_object(self): - """ - Returns True if this ObjectTag has a valid object. - Subclasses should override this method to perform any additional validation for the particular type of object tag. +class SystemDefinedTaxonomy(Taxonomy): + + class Meta: + proxy = True + + def _check_taxonomy( + self, + object_tag: ObjectTag + ) -> bool: + """ + Returns True if this is a system defined taxonomy """ - # Must have a valid object id/type: - return self.object_id and self.object_type + return ( + super()._check_taxonomy(object_tag) + and self.system_defined + ) -class ClosedObjectTag(OpenObjectTag): +class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): """ - Object tags linked to a closed taxonomy, where the available tag value options are known. + Model based system taxonomy abstract class. + + This type of taxonomies has an associated Django model in `tag_class_model()`. + Are designed to create a Tag when an ObjectTags with a new Tag. + The Tags are representations of the instances of the associated model. + + On Tag.external_id stores an identifier from the instance (`pk` as default) + and on Tag.value stores an human readable representation of the instance + (e.g. `username`). + The subclasses can override this behavior, to choose the right field. + + When an ObjectTag is created with an existing Tag, + the Tag is re-synchronized with its instance. """ class Meta: proxy = True - def _check_taxonomy(self): + def __init__( + self, + *args: Any, + **kwargs: Any + ) -> None: + """ + Checks if the `tag_class_model` is correct """ - Returns True if this ObjectTag is linked to a closed taxonomy. + assert issubclass(self.tag_class_model, models.Model) + super().__init__(*args, **kwargs) - Subclasses should override this method to perform any additional validation for the particular type of object tag. + @property + def tag_class_model(self) -> Type: """ - # Must be linked to a closed taxonomy - return self.taxonomy_id and not self.taxonomy.allow_free_text + Subclasses must implement this method to return the Django.model + class referenced by these object tags. + """ + raise NotImplementedError + + def _check_instance( + self, + object_tag: ObjectTag + ) -> bool: + """ + Returns True if the instance exists - def _check_tag(self): + Subclasses can override this method to perform their own instance validation checks. """ - Returns True if this ObjectTag has a valid tag. + intance_id = object_tag.tag.external_id + return self.tag_class_model.objects.filter(pk=intance_id).exists() - Subclasses should override this method to perform any additional validation for the particular type of object tag. + def _check_tag( + self, + object_tag: ObjectTag + ) -> bool: + """ + Returns True if pass the instance validation checks """ - # Closed taxonomies require a Tag - return bool(self.tag_id) + return ( + super()._check_tag(object_tag) + and self._check_instance(object_tag) + ) + + def _get_external_id_from_instance( + self, + instance + ) -> str: + """ + Returns the tag external id from the instance - def is_valid(self, check_taxonomy=True, check_tag=True, check_object=True) -> bool: + Subclasses can override this method to get the external id from their own models. """ - Returns True if this ObjectTag is valid for use with a closed taxonomy. + return str(instance.pk) - Subclasses should override this method to perform any additional validation for the particular type of object tag. + def _get_value_from_instance( + self, + instance + ) -> str: + """ + Returns the tag value from the instance - If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. - If `check_tag` is False, then we skip validating the object tag's tag reference. - If `check_object` is False, then we skip validating the object ID/type. + Subclasses can override this method to get the value from their own models. """ - if not super().is_valid( - check_taxonomy=check_taxonomy, - check_tag=check_tag, - check_object=check_object, - ): - return False + return str(instance.pk) + + def _get_instance(self, pk): + """ + Gets the instance from `pk` + """ + try: + return self.tag_class_model.objects.get(pk=pk) + except self.tag_class_model.DoesNotExist: + return None + + def _resync_tag(self, tag: Tag) -> Tag: + """ + Resync the tag value with the value from the instance + """ + instance = self._get_instance(tag.external_id) + if instance: + value = self._get_value_from_instance(instance) + if tag.value != value: + tag.value = value + tag.save() + return tag + + def _set_tag( + self, + tag_ref, + object_tag: ObjectTag + ) -> ObjectTag: + """ + Set or create the respective Tag on `object_tag` - if check_tag and check_taxonomy and (self.tag.taxonomy_id != self.taxonomy_id): - return False + This function is used on `tag_object`. + For this to work it is necessary that `tag_ref` + is always the `pk` of the model to which you want + to associate it. + """ + try: + tag = self.tag_set.get( + external_id=tag_ref, + ) + # Run a resync of the tag + tag = self._resync_tag(tag) + object_tag.tag = tag + except (ValueError, Tag.DoesNotExist): + # Creates a new tag with the instance + instance = self._get_instance(tag_ref) + if not instance: + return None + new_tag = Tag( + taxonomy=self, + value=self._get_value_from_instance(instance), + external_id=self._get_external_id_from_instance(instance), + ) + new_tag.save() + object_tag.tag = new_tag - return True + return object_tag + +class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): + """ + User based system taxonomy class. + """ + + class Meta: + proxy = True + + @property + def tag_class_model(self) -> Type: + """ + Associate the user model + """ + return get_user_model() + + def _get_value_from_instance( + self, + instance + ) -> str: + """ + Returns the username as tag value + """ + return instance.get_username() + + +class LanguageTaxonomy(SystemDefinedTaxonomy): + """ + Language System-defined taxonomy + + The tags are filtered and validated taking into account the + languages available in Django LANGUAGES settings var + """ + class Meta: + proxy = True + + def _get_tags_query_set(self) -> models.QuerySet: + """ + Returns a query set of available languages tags. + """ + available_langs = self._get_available_languages() + return self.tag_set.filter(external_id__in=available_langs) -# Register the ObjectTag subclasses in reverse order of how we want them considered. -register_object_tag_class(OpenObjectTag) -register_object_tag_class(ClosedObjectTag) + def _get_available_languages(cls) -> List[str]: + """ + Get available languages from Django LANGUAGE. + """ + langs = set() + for django_lang in settings.LANGUAGES: + # Split to get the language part + langs.add(django_lang[0].split('-')[0]) + return langs + + def _check_valid_language( + self, + object_tag: ObjectTag + ) -> bool: + """ + Returns True if the tag is on the available languages + """ + available_langs = self._get_available_languages() + return ( + object_tag.tag.external_id in available_langs + ) + def _check_tag( + self, + object_tag: ObjectTag + ) -> bool: + """ + Returns True if the tag is on the available languages + """ + return ( + super()._check_tag(object_tag) + and self._check_valid_language(object_tag) + ) diff --git a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py b/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py deleted file mode 100644 index a54bf05c..00000000 --- a/openedx_tagging/core/tagging/system_defined_taxonomies/object_tags.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -ObjectTags for System-defined Taxonomies -""" -from enum import Enum -from typing import List - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.exceptions import FieldDoesNotExist -from django.db import models - - -from openedx_tagging.core.tagging.models import ( - Taxonomy, - OpenObjectTag, - ClosedObjectTag, -) -from openedx_tagging.core.tagging.registry import register_object_tag_class - - -class SystemDefinedIds(Enum): - """ - System-defined taxonomy IDs - """ - LanguageTaxonomy = 1 - - -class SystemDefinedObjectTagMixin: - """ - Mixing for ObjectTags used on all system defined taxonomies - - `system_defined_taxonomy_id``is used to connect the - ObjectTag with the system defined taxonomy. - This is because there can be several ObjectTags - for the same Taxonomy, ex: - - - LanguageCourseObjectTag - - LanguageBlockObjectTag - - On the example, there are ObjectTags for the same Language - taxonomy but with different objects. - - Using this approach makes the connection between the ObjectTag - and system defined taxonomy as hardcoded and can't be changed. - """ - - system_defined_taxonomy_id = None - - def _check_system_taxonomy(self, taxonomy: Taxonomy = None): - """ - Validates if the taxonomy is system-defined and match - with the name stored in the object tag - """ - return ( - bool(taxonomy) and - taxonomy.system_defined and - taxonomy.id == self.system_defined_taxonomy_id - ) - - -class OpenSystemObjectTag(OpenObjectTag, SystemDefinedObjectTagMixin): - """ - Free-text object tag used on system-defined taxonomies - """ - - class Meta: - proxy = True - - def _check_taxonomy(self): - return ( - super()._check_taxonomy() and - self._check_system_taxonomy(self.taxonomy) - ) - - -class ClosedSystemObjectTag(ClosedObjectTag, SystemDefinedObjectTagMixin): - """ - Object tags linked to a closed system-taxonomy - """ - - class Meta: - proxy = True - - def _check_taxonomy(self): - return ( - super()._check_taxonomy() and - self._check_system_taxonomy(self.taxonomy) - ) - - -class ModelObjectTag(OpenSystemObjectTag): - """ - Object tags used with tags that relate to the id of a model - - This object tag class is not registered as it needs to have an associated model - """ - - class Meta: - proxy = True - - tag_class_model = None - - - def _check_taxonomy(self): - """ - Validates if has an associated Django model that has an Id - """ - if not super()._check_taxonomy(): - return False - - if not self.tag_class_model: - return False - - # Verify that is a Django model - if not issubclass(self.tag_class_model, models.Model): - return False - - # Verify that the model has 'id' field - try: - self.tag_class_model._meta.get_field('id') - except FieldDoesNotExist: - return False - - return True - - def _check_instance(self): - """ - Validates if the instance exists - """ - try: - intance_id = int(self.value) - except ValueError: - return False - return self.tag_class_model.objects.filter(id=intance_id).exists() - - def _check_tag(self): - """ - Validates if the instance exists - """ - if not super()._check_tag(): - return False - - # Validates if the instance exists - if not self._check_instance(): - return False - - return True - - -class UserObjectTag(ModelObjectTag): - """ - Object tags used on taxonomies associated with user model - """ - - class Meta: - proxy = True - - tag_class_model = get_user_model() - - -class LanguageObjectTag(ClosedSystemObjectTag): - """ - Object tag for Languages - - The tags are filtered and validated taking into account the - languages available in Django LANGUAGES settings var - """ - - system_defined_taxonomy_id = SystemDefinedIds.LanguageTaxonomy.value - - class Meta: - proxy = True - - @classmethod - def get_tags_query_set(cls, taxonomy: Taxonomy) -> models.QuerySet: - """ - Returns a query set of available languages tags. - """ - available_langs = cls._get_available_languages() - return taxonomy.tag_set.filter(external_id__in=available_langs) - - @classmethod - def _get_available_languages(cls) -> List[str]: - """ - Get the available languages from Django LANGUAGE. - """ - langs = set() - for django_lang in settings.LANGUAGES: - # Split to get the language part - langs.add(django_lang[0].split('-')[0]) - return langs - - def _check_tag(self): - """ - Validates if the language tag is on the available languages - """ - if not super()._check_tag(): - return False - - available_langs = self._get_available_languages() - - # Must be linked to a tag and must be an available language - if not self.tag or not self.tag.external_id in available_langs: - return False - - return True - - -# Register the object tag classes in reverse order for how we want them considered -register_object_tag_class(LanguageObjectTag) diff --git a/tests/openedx_tagging/core/fixtures/system_defined.yaml b/tests/openedx_tagging/core/fixtures/system_defined.yaml deleted file mode 100644 index 9fade190..00000000 --- a/tests/openedx_tagging/core/fixtures/system_defined.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- model: oel_tagging.taxonomy - pk: 1 - fields: - name: System Languages - description: Allows tags for any language configured for use on the instance. - enabled: true - required: false - allow_multiple: false - allow_free_text: false - system_defined: true diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml index 4174fb4f..90bca397 100644 --- a/tests/openedx_tagging/core/fixtures/tagging.yaml +++ b/tests/openedx_tagging/core/fixtures/tagging.yaml @@ -145,6 +145,13 @@ parent: 15 value: Mammalia external_id: null +- model: oel_tagging.tag + pk: 22 + fields: + taxonomy: 4 + parent: null + value: System Tag 1 + external_id: 'tag_1' - model: oel_tagging.taxonomy pk: 1 fields: @@ -165,6 +172,7 @@ allow_multiple: false allow_free_text: false system_defined: true + _taxonomy_class: openedx_tagging.core.tagging.models.LanguageTaxonomy - model: oel_tagging.taxonomy pk: 3 fields: @@ -173,5 +181,17 @@ enabled: true required: false allow_multiple: false - allow_free_text: true + allow_free_text: false + system_defined: true + _taxonomy_class: openedx_tagging.core.tagging.models.UserSystemDefinedTaxonomy +- model: oel_tagging.taxonomy + pk: 4 + fields: + name: System defined taxonomy + description: Generic System defined taxonomy + enabled: true + required: false + allow_multiple: false + allow_free_text: false system_defined: true + _taxonomy_class: openedx_tagging.core.tagging.models.SystemDefinedTaxonomy diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index b7d7ff7e..c6bb4805 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -47,15 +47,17 @@ def test_get_taxonomies(self): tax2 = tagging_api.create_taxonomy("Disabled", enabled=False) with self.assertNumQueries(1): enabled = list(tagging_api.get_taxonomies()) + assert enabled == [ tax1, self.taxonomy, self.system_taxonomy, - self.user_system_taxonomy + self.language_taxonomy, + self.user_taxonomy, ] assert str(enabled[0]) == f" ({tax1.id}) Enabled" assert str(enabled[1]) == " (1) Life on Earth" - assert str(enabled[2]) == " (2) System Languages" + assert str(enabled[2]) == " (4) System defined taxonomy" with self.assertNumQueries(1): disabled = list(tagging_api.get_taxonomies(enabled=False)) @@ -69,7 +71,8 @@ def test_get_taxonomies(self): tax1, self.taxonomy, self.system_taxonomy, - self.user_system_taxonomy + self.language_taxonomy, + self.user_taxonomy, ] def test_get_tags(self): diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index 4e78a8f4..4197f9d1 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -1,9 +1,60 @@ """ Test the tagging models """ import ddt -from django.test.testcases import TestCase -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from django.contrib.auth import get_user_model +from django.test.testcases import TestCase, override_settings + +from openedx_tagging.core.tagging.models import ( + ObjectTag, + Tag, + Taxonomy, + SystemDefinedTaxonomy, + ModelSystemDefinedTaxonomy, + UserSystemDefinedTaxonomy, +) + +test_languages = [ + ('en', 'English'), + ('az', 'Azerbaijani'), + ('id', 'Indonesian'), + ('qu', 'Quechua'), + ('zu', 'Zulu'), +] + + +class EmptyTestClass: + """ + Empty class used for testing + """ + + +class InvalidModelTaxonomy(ModelSystemDefinedTaxonomy): + """ + Model used for testing + """ + @property + def tag_class_model(self): + return EmptyTestClass + + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' + + +class TestModelTaxonomy(ModelSystemDefinedTaxonomy): + """ + Model used for testing + """ + @property + def tag_class_model(self): + return get_user_model() + + class Meta: + proxy = True + managed = False + app_label = 'oel_tagging' def get_tag(value): @@ -23,14 +74,26 @@ class TestTagTaxonomyMixin: def setUp(self): super().setUp() self.taxonomy = Taxonomy.objects.get(name="Life on Earth") - self.system_taxonomy = Taxonomy.objects.get(name="System Languages") - self.user_system_taxonomy = Taxonomy.objects.get(name="User Authors") + self.system_taxonomy = Taxonomy.objects.get(name="System defined taxonomy").cast() + self.language_taxonomy = Taxonomy.objects.get(name="System Languages").cast() + self.user_taxonomy = Taxonomy.objects.get(name="User Authors").cast() self.archaea = get_tag("Archaea") self.archaebacteria = get_tag("Archaebacteria") self.bacteria = get_tag("Bacteria") self.eubacteria = get_tag("Eubacteria") self.chordata = get_tag("Chordata") self.mammalia = get_tag("Mammalia") + self.system_taxonomy_tag = get_tag("System Tag 1") + self.user_1 = get_user_model()( + id=1, + username='test_user_1', + ) + self.user_1.save() + self.user_2 = get_user_model()( + id=2, + username='test_user_2', + ) + self.user_2.save() # Domain tags (depth=0) # https://en.wikipedia.org/wiki/Domain_(biology) @@ -127,9 +190,9 @@ def test_representations(self): str(self.taxonomy) == repr(self.taxonomy) == " (1) Life on Earth" ) assert ( - str(self.system_taxonomy) - == repr(self.system_taxonomy) - == " (2) System Languages" + str(self.language_taxonomy) + == repr(self.language_taxonomy) + == " (2) System Languages" ) assert str(self.bacteria) == repr(self.bacteria) == " (1) Bacteria" @@ -403,3 +466,189 @@ def test_tag_object_invalid_tag(self): "biology101", ) assert "Invalid object tag for taxonomy" in str(exc.exception) + + +@ddt.ddt +class TestSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for System defined Taxonomy + """ + + @ddt.data( + ("system_taxonomy", "system_taxonomy", True), # Valid + ("taxonomy", "taxonomy", False), # Not a system defined + ("system_taxonomy", "taxonomy", False), # Testing parent validations + ) + @ddt.unpack + def test_validations(self, taxonomy, second_taxonomy, expected): + taxonomy = getattr(self, taxonomy) + taxonomy.taxonomy_class = SystemDefinedTaxonomy + taxonomy = taxonomy.cast() + + second_taxonomy = getattr(self, second_taxonomy) + + assert taxonomy.validate_object_tag( + object_tag=ObjectTag( + object_id='id', + taxonomy=second_taxonomy + ), + check_taxonomy=True, + check_tag=False, + check_object=False, + ) == expected + + +@ddt.ddt +class TestModelSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for Model Model System defined taxonomy + """ + + @ddt.data( + (ModelSystemDefinedTaxonomy, NotImplementedError), + (InvalidModelTaxonomy, AssertionError), + (UserSystemDefinedTaxonomy, None), + ) + @ddt.unpack + def test_implementation_error(self, taxonomy_cls, expected_exception): + if not expected_exception: + assert taxonomy_cls() + else: + with self.assertRaises(expected_exception): + taxonomy_cls() + + @ddt.data( + (1, "tag_id"), # Valid + (0, "tag_id"), # Invalid user + (1, None) # Testing parent validations + ) + @ddt.unpack + def test_validations(self, tag_external_id, tag_id): + tag = Tag( + id=tag_id, + taxonomy=self.user_taxonomy, + value='_val', + external_id=tag_external_id, + ) + object_tag = ObjectTag( + object_id='id', + tag=tag, + ) + + self.user_taxonomy.validate_object_tag( + object_tag=object_tag, + check_object=False, + check_taxonomy=False, + check_tag=True + ) + + def test_tag_object_invalid_user(self): + # Test user that doesn't exist + with self.assertRaises(ValueError): + self.user_taxonomy.tag_object( + tags=[4], + object_id='object_id' + ) + + def _tag_object(self): + return self.user_taxonomy.tag_object( + tags=[self.user_1.id], + object_id='object_id' + ) + + def test_tag_object_tag_creation(self): + # Test creation of a new Tag with user taxonomy + assert self.user_taxonomy.tag_set.count() == 0 + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + # Test parent functions + taxonomy = TestModelTaxonomy( + name='Test', + description='Test', + system_defined=True, + ) + taxonomy.save() + assert taxonomy.tag_set.count() == 0 + updated_tags = taxonomy.tag_object( + tags=[self.user_1.id], + object_id='object_id' + ) + assert taxonomy.tag_set.count() == 1 + assert taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == str(self.user_1.id) + + + def test_tag_object_existing_tag(self): + # Test add an existing Tag + self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + def test_tag_object_resync(self): + self._tag_object() + + self.user_1.username = "new_username" + self.user_1.save() + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + def test_tag_object_delete_user(self): + # Test after delete user + self._tag_object() + user_1_id = self.user_1.id + self.user_1.delete() + with self.assertRaises(ValueError): + self.user_taxonomy.tag_object( + tags=[user_1_id], + object_id='object_id', + ) + +@ddt.ddt +@override_settings(LANGUAGES=test_languages) +class TestLanguageTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for Language taxonomy + """ + + @ddt.data( + ('en', 'tag_id'), # Valid + ('es', 'tag_id'), # Not available lang + ('en', None), # Test parent validations + ) + @ddt.unpack + def test_validations(self, lang, tag_id): + tag = Tag( + id=tag_id, + taxonomy=self.language_taxonomy, + value='_val', + external_id=lang, + ) + object_tag = ObjectTag( + object_id='id', + tag=tag, + ) + self.language_taxonomy.validate_object_tag( + object_tag=object_tag, + check_object=False, + check_taxonomy=False, + check_tag=True, + ) + + def test_get_tags(self): + tags = self.language_taxonomy.get_tags() + for tag in tags: + assert tag.external_id in test_languages + assert tag.annotated_field == 0 diff --git a/tests/openedx_tagging/core/tagging/test_system_defined.py b/tests/openedx_tagging/core/tagging/test_system_defined.py deleted file mode 100644 index 9de7fb3c..00000000 --- a/tests/openedx_tagging/core/tagging/test_system_defined.py +++ /dev/null @@ -1,236 +0,0 @@ -""" Test the System-defined taxonomies and object tags """ - -import ddt - -from django.contrib.auth import get_user_model -from django.db import models -from django.test.testcases import TestCase, override_settings - -from openedx_tagging.core.tagging.system_defined_taxonomies.object_tags import ( - ModelObjectTag, - UserObjectTag, - LanguageObjectTag, -) -from openedx_tagging.core.tagging.models import Tag, Taxonomy - -from .test_models import TestTagTaxonomyMixin - -test_languages = [ - ('en', 'English'), - ('az', 'Azerbaijani'), - ('id', 'Indonesian'), - ('qu', 'Quechua'), - ('zu', 'Zulu'), -] - - -class EmptyTestClass: - """ - Empty class used for testing - """ - - -class EmptyTestModel(models.Model): - """ - Model used for testing - """ - mi_id = models.AutoField(primary_key=True) - - class Meta: - managed = False - app_label = 'oel_tagging' - - -class EmptyModelObjectTag(ModelObjectTag): - """ - Model ObjectTag used for testing - """ - - system_defined_taxonomy_id = 3 - - class Meta: - proxy = True - managed = False - app_label = 'oel_tagging' - - -class NotDjangoModelObjectTag(ModelObjectTag): - """ - Model ObjectTag used for testing - """ - - system_defined_taxonomy_id = 3 - - class Meta: - proxy = True - managed = False - app_label = 'oel_tagging' - - tag_class_model = EmptyTestClass - - -class NotIdModelObjectTag(ModelObjectTag): - """ - Model ObjectTag used for testing - """ - - system_defined_taxonomy_id = 3 - - class Meta: - proxy = True - managed = False - app_label = 'oel_tagging' - - tag_class_model = EmptyTestModel - - -class TestUserObjectTag(UserObjectTag): - """ - User ObjectTag used for testing - """ - - system_defined_taxonomy_id = 3 - - class Meta: - proxy = True - managed = False - app_label = 'oel_tagging' - - -@ddt.ddt -class TestSystemDefinedObjectTags(TestTagTaxonomyMixin, TestCase): - """ - Test for generic system defined object tags - """ - def test_system_defined_is_valid(self): - # Valid - assert TestUserObjectTag()._check_system_taxonomy(taxonomy=self.user_system_taxonomy) - - # Null taxonomy - assert not UserObjectTag()._check_system_taxonomy() - - # Not system defined taxonomy - assert not UserObjectTag()._check_system_taxonomy(taxonomy=self.taxonomy) - - # Not connected with the taxonomy - assert not UserObjectTag()._check_system_taxonomy(taxonomy=self.user_system_taxonomy) - - @ddt.data( - (EmptyModelObjectTag, False), # Without associated model - (NotDjangoModelObjectTag, False), # Associated class is not a Django model - (NotIdModelObjectTag, False), # Associated model has not 'id' field - (ModelObjectTag, False), # Testing parent class validations - (TestUserObjectTag, True), #Valid - ) - @ddt.unpack - def test_model_object_is_valid(self, object_tag, assert_value): - args = { - 'taxonomy': self.user_system_taxonomy, - 'object_id': 'id', - 'object_type': 'object', - 'value': 'value', - } - result = object_tag(**args).is_valid(check_object=False, check_tag=False, check_taxonomy=True) - self.assertEqual(result, assert_value) - - @ddt.data( - (None, True), # Valid - ('user_id', False), # Invalid user id - ('10000', False), # User don't exits - (None, False), # Testing parent class validations - ) - @ddt.unpack - def test_user_object_is_valid(self, value, assert_value): - if assert_value: - user = get_user_model()( - username='username', - email='email' - ) - user.save() - value = user.id - - object_tag = TestUserObjectTag( - taxonomy=self.user_system_taxonomy, - object_id='id', - object_type='object', - value=value, - ) - - result = object_tag.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - self.assertEqual(result, assert_value) - - -@override_settings(LANGUAGES=test_languages) -class TestLanguageObjectClass(TestCase): - """ - Test for Language object class - """ - - fixtures = [ - "tests/openedx_tagging/core/fixtures/system_defined.yaml", - "openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml" - ] - - def setUp(self): - super().setUp() - self.taxonomy = Taxonomy.objects.get(name="System Languages") - self.expected_langs_ids = sorted([item[0] for item in test_languages]) - self.expected_langs_values = sorted([item[1] for item in test_languages]) - self.english_tag = Tag.objects.get(value="English") - self.spanish_tag = Tag.objects.get(value="Spanish") - - def test_get_available_languages(self): - langs = LanguageObjectTag._get_available_languages() - self.assertEqual(sorted(langs), self.expected_langs_ids) - - def test_is_valid(self): - valid_object_tag = LanguageObjectTag( - taxonomy=self.taxonomy, - object_id='id 1', - object_type='object', - tag=self.english_tag, - ) - - invalid_onbject_tag_1 = LanguageObjectTag( - taxonomy=self.taxonomy, - object_id='id 2', - object_type='object', - tag=self.spanish_tag, - ) - - invalid_onbject_tag_2 = LanguageObjectTag( - taxonomy=self.taxonomy, - object_id='id 2', - object_type='object', - ) - - # Tag is not in available languages - assert not invalid_onbject_tag_1.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - # Testing parent class validations - assert not invalid_onbject_tag_2.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - # Valid - assert valid_object_tag.is_valid( - check_taxonomy=True, - check_object=True, - check_tag=True, - ) - - def test_get_tags_query_set(self): - tags = LanguageObjectTag.get_tags_query_set(self.taxonomy) - for tag in tags: - self.assertIn(tag.value, self.expected_langs_values) From 9b72fec5450ab5d6823d9ab14bf8c68774b49b05 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 20 Jul 2023 02:22:05 +0930 Subject: [PATCH 11/20] style: linting and formatting --- .../commands/build_language_fixture.py | 9 +- .../tagging/migrations/0003_system_defined.py | 55 +++++----- openedx_tagging/core/tagging/models.py | 101 +++++------------- .../openedx_tagging/core/tagging/test_api.py | 2 +- .../core/tagging/test_models.py | 86 +++++++-------- 5 files changed, 100 insertions(+), 153 deletions(-) diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py index 0db72495..5735ac16 100644 --- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -5,16 +5,16 @@ This function is intended to be used only once, but can be edited in the future if more data needs to be added to the fixture. """ -import urllib.request import json +import urllib.request from django.core.management.base import BaseCommand endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json" output = "./openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml" -class Command(BaseCommand): +class Command(BaseCommand): def handle(self, **options): json_data = self.download_json() self.build_fixture(json_data) @@ -26,7 +26,7 @@ def download_json(self): def build_fixture(self, json_data): tag_pk = 1 - with open(output, 'w') as output_file: + with open(output, "w") as output_file: for lang_data in json_data: lang_value = self.get_lang_value(lang_data) lang_code = lang_data["alpha2"] @@ -43,6 +43,5 @@ def get_lang_value(self, lang_data): """ Gets the lang value. Some languages has many values. """ - lang_list = lang_data["English"].split(';') + lang_list = lang_data["English"].split(";") return lang_list[0] - \ No newline at end of file diff --git a/openedx_tagging/core/tagging/migrations/0003_system_defined.py b/openedx_tagging/core/tagging/migrations/0003_system_defined.py index d4c09425..99637a49 100644 --- a/openedx_tagging/core/tagging/migrations/0003_system_defined.py +++ b/openedx_tagging/core/tagging/migrations/0003_system_defined.py @@ -4,54 +4,49 @@ class Migration(migrations.Migration): - dependencies = [ - ('oel_tagging', '0002_auto_20230718_2026'), + ("oel_tagging", "0002_auto_20230718_2026"), ] operations = [ migrations.CreateModel( - name='SystemDefinedTaxonomy', - fields=[ - ], + name="SystemDefinedTaxonomy", + fields=[], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + "proxy": True, + "indexes": [], + "constraints": [], }, - bases=('oel_tagging.taxonomy',), + bases=("oel_tagging.taxonomy",), ), migrations.CreateModel( - name='LanguageTaxonomy', - fields=[ - ], + name="LanguageTaxonomy", + fields=[], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + "proxy": True, + "indexes": [], + "constraints": [], }, - bases=('oel_tagging.systemdefinedtaxonomy',), + bases=("oel_tagging.systemdefinedtaxonomy",), ), migrations.CreateModel( - name='ModelSystemDefinedTaxonomy', - fields=[ - ], + name="ModelSystemDefinedTaxonomy", + fields=[], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + "proxy": True, + "indexes": [], + "constraints": [], }, - bases=('oel_tagging.systemdefinedtaxonomy',), + bases=("oel_tagging.systemdefinedtaxonomy",), ), migrations.CreateModel( - name='UserSystemDefinedTaxonomy', - fields=[ - ], + name="UserSystemDefinedTaxonomy", + fields=[], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + "proxy": True, + "indexes": [], + "constraints": [], }, - bases=('oel_tagging.modelsystemdefinedtaxonomy',), + bases=("oel_tagging.modelsystemdefinedtaxonomy",), ), ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index eccf40f2..0f0494d6 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -245,7 +245,7 @@ def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": self.visible_to_authors = taxonomy.visible_to_authors self._taxonomy_class = taxonomy._taxonomy_class return self - + def _get_tags_query_set(self) -> models.QuerySet: """ Base query set for used on `get_tags()` @@ -414,7 +414,7 @@ def tag_object( ) object_tag = self._set_tag(tag_ref, object_tag) - + if not object_tag or not self.validate_object_tag(object_tag): raise ValueError( _(f"Invalid object tag for taxonomy ({self.id}): {tag_ref}") @@ -652,48 +652,37 @@ def copy(self, object_tag: "ObjectTag") -> "ObjectTag": class SystemDefinedTaxonomy(Taxonomy): - class Meta: proxy = True - def _check_taxonomy( - self, - object_tag: ObjectTag - ) -> bool: + def _check_taxonomy(self, object_tag: ObjectTag) -> bool: """ Returns True if this is a system defined taxonomy """ - return ( - super()._check_taxonomy(object_tag) - and self.system_defined - ) + return super()._check_taxonomy(object_tag) and self.system_defined class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): """ Model based system taxonomy abstract class. - + This type of taxonomies has an associated Django model in `tag_class_model()`. - Are designed to create a Tag when an ObjectTags with a new Tag. + Are designed to create a Tag when an ObjectTags with a new Tag. The Tags are representations of the instances of the associated model. On Tag.external_id stores an identifier from the instance (`pk` as default) and on Tag.value stores an human readable representation of the instance - (e.g. `username`). + (e.g. `username`). The subclasses can override this behavior, to choose the right field. - When an ObjectTag is created with an existing Tag, + When an ObjectTag is created with an existing Tag, the Tag is re-synchronized with its instance. """ class Meta: proxy = True - def __init__( - self, - *args: Any, - **kwargs: Any - ) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: """ Checks if the `tag_class_model` is correct """ @@ -707,11 +696,8 @@ def tag_class_model(self) -> Type: class referenced by these object tags. """ raise NotImplementedError - - def _check_instance( - self, - object_tag: ObjectTag - ) -> bool: + + def _check_instance(self, object_tag: ObjectTag) -> bool: """ Returns True if the instance exists @@ -720,22 +706,13 @@ def _check_instance( intance_id = object_tag.tag.external_id return self.tag_class_model.objects.filter(pk=intance_id).exists() - def _check_tag( - self, - object_tag: ObjectTag - ) -> bool: + def _check_tag(self, object_tag: ObjectTag) -> bool: """ Returns True if pass the instance validation checks """ - return ( - super()._check_tag(object_tag) - and self._check_instance(object_tag) - ) - - def _get_external_id_from_instance( - self, - instance - ) -> str: + return super()._check_tag(object_tag) and self._check_instance(object_tag) + + def _get_external_id_from_instance(self, instance) -> str: """ Returns the tag external id from the instance @@ -743,17 +720,14 @@ def _get_external_id_from_instance( """ return str(instance.pk) - def _get_value_from_instance( - self, - instance - ) -> str: + def _get_value_from_instance(self, instance) -> str: """ Returns the tag value from the instance Subclasses can override this method to get the value from their own models. """ return str(instance.pk) - + def _get_instance(self, pk): """ Gets the instance from `pk` @@ -762,7 +736,7 @@ def _get_instance(self, pk): return self.tag_class_model.objects.get(pk=pk) except self.tag_class_model.DoesNotExist: return None - + def _resync_tag(self, tag: Tag) -> Tag: """ Resync the tag value with the value from the instance @@ -775,16 +749,12 @@ def _resync_tag(self, tag: Tag) -> Tag: tag.save() return tag - def _set_tag( - self, - tag_ref, - object_tag: ObjectTag - ) -> ObjectTag: + def _set_tag(self, tag_ref, object_tag: ObjectTag) -> ObjectTag: """ Set or create the respective Tag on `object_tag` This function is used on `tag_object`. - For this to work it is necessary that `tag_ref` + For this to work it is necessary that `tag_ref` is always the `pk` of the model to which you want to associate it. """ @@ -810,6 +780,7 @@ def _set_tag( return object_tag + class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): """ User based system taxonomy class. @@ -825,10 +796,7 @@ def tag_class_model(self) -> Type: """ return get_user_model() - def _get_value_from_instance( - self, - instance - ) -> str: + def _get_value_from_instance(self, instance) -> str: """ Returns the username as tag value """ @@ -839,7 +807,7 @@ class LanguageTaxonomy(SystemDefinedTaxonomy): """ Language System-defined taxonomy - The tags are filtered and validated taking into account the + The tags are filtered and validated taking into account the languages available in Django LANGUAGES settings var """ @@ -860,29 +828,18 @@ def _get_available_languages(cls) -> List[str]: langs = set() for django_lang in settings.LANGUAGES: # Split to get the language part - langs.add(django_lang[0].split('-')[0]) + langs.add(django_lang[0].split("-")[0]) return langs - - def _check_valid_language( - self, - object_tag: ObjectTag - ) -> bool: + + def _check_valid_language(self, object_tag: ObjectTag) -> bool: """ Returns True if the tag is on the available languages """ available_langs = self._get_available_languages() - return ( - object_tag.tag.external_id in available_langs - ) + return object_tag.tag.external_id in available_langs - def _check_tag( - self, - object_tag: ObjectTag - ) -> bool: + def _check_tag(self, object_tag: ObjectTag) -> bool: """ Returns True if the tag is on the available languages """ - return ( - super()._check_tag(object_tag) - and self._check_valid_language(object_tag) - ) + return super()._check_tag(object_tag) and self._check_valid_language(object_tag) diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index c6bb4805..521c4814 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -52,7 +52,7 @@ def test_get_taxonomies(self): tax1, self.taxonomy, self.system_taxonomy, - self.language_taxonomy, + self.language_taxonomy, self.user_taxonomy, ] assert str(enabled[0]) == f" ({tax1.id}) Enabled" diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index 4197f9d1..dadeb5f7 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -1,25 +1,24 @@ """ Test the tagging models """ import ddt - from django.contrib.auth import get_user_model from django.test.testcases import TestCase, override_settings from openedx_tagging.core.tagging.models import ( + ModelSystemDefinedTaxonomy, ObjectTag, + SystemDefinedTaxonomy, Tag, Taxonomy, - SystemDefinedTaxonomy, - ModelSystemDefinedTaxonomy, UserSystemDefinedTaxonomy, ) test_languages = [ - ('en', 'English'), - ('az', 'Azerbaijani'), - ('id', 'Indonesian'), - ('qu', 'Quechua'), - ('zu', 'Zulu'), + ("en", "English"), + ("az", "Azerbaijani"), + ("id", "Indonesian"), + ("qu", "Quechua"), + ("zu", "Zulu"), ] @@ -33,6 +32,7 @@ class InvalidModelTaxonomy(ModelSystemDefinedTaxonomy): """ Model used for testing """ + @property def tag_class_model(self): return EmptyTestClass @@ -40,13 +40,14 @@ def tag_class_model(self): class Meta: proxy = True managed = False - app_label = 'oel_tagging' + app_label = "oel_tagging" class TestModelTaxonomy(ModelSystemDefinedTaxonomy): """ Model used for testing """ + @property def tag_class_model(self): return get_user_model() @@ -54,7 +55,7 @@ def tag_class_model(self): class Meta: proxy = True managed = False - app_label = 'oel_tagging' + app_label = "oel_tagging" def get_tag(value): @@ -74,7 +75,9 @@ class TestTagTaxonomyMixin: def setUp(self): super().setUp() self.taxonomy = Taxonomy.objects.get(name="Life on Earth") - self.system_taxonomy = Taxonomy.objects.get(name="System defined taxonomy").cast() + self.system_taxonomy = Taxonomy.objects.get( + name="System defined taxonomy" + ).cast() self.language_taxonomy = Taxonomy.objects.get(name="System Languages").cast() self.user_taxonomy = Taxonomy.objects.get(name="User Authors").cast() self.archaea = get_tag("Archaea") @@ -86,12 +89,12 @@ def setUp(self): self.system_taxonomy_tag = get_tag("System Tag 1") self.user_1 = get_user_model()( id=1, - username='test_user_1', + username="test_user_1", ) self.user_1.save() self.user_2 = get_user_model()( id=2, - username='test_user_2', + username="test_user_2", ) self.user_2.save() @@ -487,15 +490,15 @@ def test_validations(self, taxonomy, second_taxonomy, expected): second_taxonomy = getattr(self, second_taxonomy) - assert taxonomy.validate_object_tag( - object_tag=ObjectTag( - object_id='id', - taxonomy=second_taxonomy - ), - check_taxonomy=True, - check_tag=False, - check_object=False, - ) == expected + assert ( + taxonomy.validate_object_tag( + object_tag=ObjectTag(object_id="id", taxonomy=second_taxonomy), + check_taxonomy=True, + check_tag=False, + check_object=False, + ) + == expected + ) @ddt.ddt @@ -520,18 +523,18 @@ def test_implementation_error(self, taxonomy_cls, expected_exception): @ddt.data( (1, "tag_id"), # Valid (0, "tag_id"), # Invalid user - (1, None) # Testing parent validations + (1, None), # Testing parent validations ) @ddt.unpack def test_validations(self, tag_external_id, tag_id): tag = Tag( id=tag_id, taxonomy=self.user_taxonomy, - value='_val', + value="_val", external_id=tag_external_id, ) object_tag = ObjectTag( - object_id='id', + object_id="id", tag=tag, ) @@ -539,21 +542,17 @@ def test_validations(self, tag_external_id, tag_id): object_tag=object_tag, check_object=False, check_taxonomy=False, - check_tag=True + check_tag=True, ) def test_tag_object_invalid_user(self): # Test user that doesn't exist with self.assertRaises(ValueError): - self.user_taxonomy.tag_object( - tags=[4], - object_id='object_id' - ) + self.user_taxonomy.tag_object(tags=[4], object_id="object_id") def _tag_object(self): return self.user_taxonomy.tag_object( - tags=[self.user_1.id], - object_id='object_id' + tags=[self.user_1.id], object_id="object_id" ) def test_tag_object_tag_creation(self): @@ -567,23 +566,19 @@ def test_tag_object_tag_creation(self): # Test parent functions taxonomy = TestModelTaxonomy( - name='Test', - description='Test', + name="Test", + description="Test", system_defined=True, ) taxonomy.save() assert taxonomy.tag_set.count() == 0 - updated_tags = taxonomy.tag_object( - tags=[self.user_1.id], - object_id='object_id' - ) + updated_tags = taxonomy.tag_object(tags=[self.user_1.id], object_id="object_id") assert taxonomy.tag_set.count() == 1 assert taxonomy.tag_set.count() == 1 assert len(updated_tags) == 1 assert updated_tags[0].tag.external_id == str(self.user_1.id) assert updated_tags[0].tag.value == str(self.user_1.id) - def test_tag_object_existing_tag(self): # Test add an existing Tag self._tag_object() @@ -613,9 +608,10 @@ def test_tag_object_delete_user(self): with self.assertRaises(ValueError): self.user_taxonomy.tag_object( tags=[user_1_id], - object_id='object_id', + object_id="object_id", ) + @ddt.ddt @override_settings(LANGUAGES=test_languages) class TestLanguageTaxonomy(TestTagTaxonomyMixin, TestCase): @@ -624,20 +620,20 @@ class TestLanguageTaxonomy(TestTagTaxonomyMixin, TestCase): """ @ddt.data( - ('en', 'tag_id'), # Valid - ('es', 'tag_id'), # Not available lang - ('en', None), # Test parent validations + ("en", "tag_id"), # Valid + ("es", "tag_id"), # Not available lang + ("en", None), # Test parent validations ) @ddt.unpack def test_validations(self, lang, tag_id): tag = Tag( id=tag_id, taxonomy=self.language_taxonomy, - value='_val', + value="_val", external_id=lang, ) object_tag = ObjectTag( - object_id='id', + object_id="id", tag=tag, ) self.language_taxonomy.validate_object_tag( From 69eeece81494120a654a345dcfcb83a56c7f3880 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 20 Jul 2023 01:59:48 +0930 Subject: [PATCH 12/20] refactor: use negative numbers as primary keys for system taxonomies and tags --- .../tagging/fixtures/language_taxonomy.yaml | 368 +++++++++--------- .../core/tagging/fixtures/system_defined.yaml | 2 +- .../commands/build_language_fixture.py | 5 +- 3 files changed, 188 insertions(+), 187 deletions(-) diff --git a/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml index efe84cdb..06d51e15 100644 --- a/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml +++ b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml @@ -1,1286 +1,1286 @@ - model: oel_tagging.tag - pk: 1 + pk: -1 fields: taxonomy: 1 parent: null value: Afar external_id: aa - model: oel_tagging.tag - pk: 2 + pk: -2 fields: taxonomy: 1 parent: null value: Abkhazian external_id: ab - model: oel_tagging.tag - pk: 3 + pk: -3 fields: taxonomy: 1 parent: null value: Avestan external_id: ae - model: oel_tagging.tag - pk: 4 + pk: -4 fields: taxonomy: 1 parent: null value: Afrikaans external_id: af - model: oel_tagging.tag - pk: 5 + pk: -5 fields: taxonomy: 1 parent: null value: Akan external_id: ak - model: oel_tagging.tag - pk: 6 + pk: -6 fields: taxonomy: 1 parent: null value: Amharic external_id: am - model: oel_tagging.tag - pk: 7 + pk: -7 fields: taxonomy: 1 parent: null value: Aragonese external_id: an - model: oel_tagging.tag - pk: 8 + pk: -8 fields: taxonomy: 1 parent: null value: Arabic external_id: ar - model: oel_tagging.tag - pk: 9 + pk: -9 fields: taxonomy: 1 parent: null value: Assamese external_id: as - model: oel_tagging.tag - pk: 10 + pk: -10 fields: taxonomy: 1 parent: null value: Avaric external_id: av - model: oel_tagging.tag - pk: 11 + pk: -11 fields: taxonomy: 1 parent: null value: Aymara external_id: ay - model: oel_tagging.tag - pk: 12 + pk: -12 fields: taxonomy: 1 parent: null value: Azerbaijani external_id: az - model: oel_tagging.tag - pk: 13 + pk: -13 fields: taxonomy: 1 parent: null value: Bashkir external_id: ba - model: oel_tagging.tag - pk: 14 + pk: -14 fields: taxonomy: 1 parent: null value: Belarusian external_id: be - model: oel_tagging.tag - pk: 15 + pk: -15 fields: taxonomy: 1 parent: null value: Bulgarian external_id: bg - model: oel_tagging.tag - pk: 16 + pk: -16 fields: taxonomy: 1 parent: null value: Bihari languages external_id: bh - model: oel_tagging.tag - pk: 17 + pk: -17 fields: taxonomy: 1 parent: null value: Bislama external_id: bi - model: oel_tagging.tag - pk: 18 + pk: -18 fields: taxonomy: 1 parent: null value: Bambara external_id: bm - model: oel_tagging.tag - pk: 19 + pk: -19 fields: taxonomy: 1 parent: null value: Bengali external_id: bn - model: oel_tagging.tag - pk: 20 + pk: -20 fields: taxonomy: 1 parent: null value: Tibetan external_id: bo - model: oel_tagging.tag - pk: 21 + pk: -21 fields: taxonomy: 1 parent: null value: Breton external_id: br - model: oel_tagging.tag - pk: 22 + pk: -22 fields: taxonomy: 1 parent: null value: Bosnian external_id: bs - model: oel_tagging.tag - pk: 23 + pk: -23 fields: taxonomy: 1 parent: null value: Catalan external_id: ca - model: oel_tagging.tag - pk: 24 + pk: -24 fields: taxonomy: 1 parent: null value: Chechen external_id: ce - model: oel_tagging.tag - pk: 25 + pk: -25 fields: taxonomy: 1 parent: null value: Chamorro external_id: ch - model: oel_tagging.tag - pk: 26 + pk: -26 fields: taxonomy: 1 parent: null value: Corsican external_id: co - model: oel_tagging.tag - pk: 27 + pk: -27 fields: taxonomy: 1 parent: null value: Cree external_id: cr - model: oel_tagging.tag - pk: 28 + pk: -28 fields: taxonomy: 1 parent: null value: Czech external_id: cs - model: oel_tagging.tag - pk: 29 + pk: -29 fields: taxonomy: 1 parent: null value: Church Slavic external_id: cu - model: oel_tagging.tag - pk: 30 + pk: -30 fields: taxonomy: 1 parent: null value: Chuvash external_id: cv - model: oel_tagging.tag - pk: 31 + pk: -31 fields: taxonomy: 1 parent: null value: Welsh external_id: cy - model: oel_tagging.tag - pk: 32 + pk: -32 fields: taxonomy: 1 parent: null value: Danish external_id: da - model: oel_tagging.tag - pk: 33 + pk: -33 fields: taxonomy: 1 parent: null value: German external_id: de - model: oel_tagging.tag - pk: 34 + pk: -34 fields: taxonomy: 1 parent: null value: Divehi external_id: dv - model: oel_tagging.tag - pk: 35 + pk: -35 fields: taxonomy: 1 parent: null value: Dzongkha external_id: dz - model: oel_tagging.tag - pk: 36 + pk: -36 fields: taxonomy: 1 parent: null value: Ewe external_id: ee - model: oel_tagging.tag - pk: 37 + pk: -37 fields: taxonomy: 1 parent: null value: Greek, Modern (1453-) external_id: el - model: oel_tagging.tag - pk: 38 + pk: -38 fields: taxonomy: 1 parent: null value: English external_id: en - model: oel_tagging.tag - pk: 39 + pk: -39 fields: taxonomy: 1 parent: null value: Esperanto external_id: eo - model: oel_tagging.tag - pk: 40 + pk: -40 fields: taxonomy: 1 parent: null value: Spanish external_id: es - model: oel_tagging.tag - pk: 41 + pk: -41 fields: taxonomy: 1 parent: null value: Estonian external_id: et - model: oel_tagging.tag - pk: 42 + pk: -42 fields: taxonomy: 1 parent: null value: Basque external_id: eu - model: oel_tagging.tag - pk: 43 + pk: -43 fields: taxonomy: 1 parent: null value: Persian external_id: fa - model: oel_tagging.tag - pk: 44 + pk: -44 fields: taxonomy: 1 parent: null value: Fulah external_id: ff - model: oel_tagging.tag - pk: 45 + pk: -45 fields: taxonomy: 1 parent: null value: Finnish external_id: fi - model: oel_tagging.tag - pk: 46 + pk: -46 fields: taxonomy: 1 parent: null value: Fijian external_id: fj - model: oel_tagging.tag - pk: 47 + pk: -47 fields: taxonomy: 1 parent: null value: Faroese external_id: fo - model: oel_tagging.tag - pk: 48 + pk: -48 fields: taxonomy: 1 parent: null value: French external_id: fr - model: oel_tagging.tag - pk: 49 + pk: -49 fields: taxonomy: 1 parent: null value: Western Frisian external_id: fy - model: oel_tagging.tag - pk: 50 + pk: -50 fields: taxonomy: 1 parent: null value: Irish external_id: ga - model: oel_tagging.tag - pk: 51 + pk: -51 fields: taxonomy: 1 parent: null value: Gaelic external_id: gd - model: oel_tagging.tag - pk: 52 + pk: -52 fields: taxonomy: 1 parent: null value: Galician external_id: gl - model: oel_tagging.tag - pk: 53 + pk: -53 fields: taxonomy: 1 parent: null value: Guarani external_id: gn - model: oel_tagging.tag - pk: 54 + pk: -54 fields: taxonomy: 1 parent: null value: Gujarati external_id: gu - model: oel_tagging.tag - pk: 55 + pk: -55 fields: taxonomy: 1 parent: null value: Manx external_id: gv - model: oel_tagging.tag - pk: 56 + pk: -56 fields: taxonomy: 1 parent: null value: Hausa external_id: ha - model: oel_tagging.tag - pk: 57 + pk: -57 fields: taxonomy: 1 parent: null value: Hebrew external_id: he - model: oel_tagging.tag - pk: 58 + pk: -58 fields: taxonomy: 1 parent: null value: Hindi external_id: hi - model: oel_tagging.tag - pk: 59 + pk: -59 fields: taxonomy: 1 parent: null value: Hiri Motu external_id: ho - model: oel_tagging.tag - pk: 60 + pk: -60 fields: taxonomy: 1 parent: null value: Croatian external_id: hr - model: oel_tagging.tag - pk: 61 + pk: -61 fields: taxonomy: 1 parent: null value: Haitian external_id: ht - model: oel_tagging.tag - pk: 62 + pk: -62 fields: taxonomy: 1 parent: null value: Hungarian external_id: hu - model: oel_tagging.tag - pk: 63 + pk: -63 fields: taxonomy: 1 parent: null value: Armenian external_id: hy - model: oel_tagging.tag - pk: 64 + pk: -64 fields: taxonomy: 1 parent: null value: Herero external_id: hz - model: oel_tagging.tag - pk: 65 + pk: -65 fields: taxonomy: 1 parent: null value: Interlingua (International Auxiliary Language Association) external_id: ia - model: oel_tagging.tag - pk: 66 + pk: -66 fields: taxonomy: 1 parent: null value: Indonesian external_id: id - model: oel_tagging.tag - pk: 67 + pk: -67 fields: taxonomy: 1 parent: null value: Interlingue external_id: ie - model: oel_tagging.tag - pk: 68 + pk: -68 fields: taxonomy: 1 parent: null value: Igbo external_id: ig - model: oel_tagging.tag - pk: 69 + pk: -69 fields: taxonomy: 1 parent: null value: Sichuan Yi external_id: ii - model: oel_tagging.tag - pk: 70 + pk: -70 fields: taxonomy: 1 parent: null value: Inupiaq external_id: ik - model: oel_tagging.tag - pk: 71 + pk: -71 fields: taxonomy: 1 parent: null value: Ido external_id: io - model: oel_tagging.tag - pk: 72 + pk: -72 fields: taxonomy: 1 parent: null value: Icelandic external_id: is - model: oel_tagging.tag - pk: 73 + pk: -73 fields: taxonomy: 1 parent: null value: Italian external_id: it - model: oel_tagging.tag - pk: 74 + pk: -74 fields: taxonomy: 1 parent: null value: Inuktitut external_id: iu - model: oel_tagging.tag - pk: 75 + pk: -75 fields: taxonomy: 1 parent: null value: Japanese external_id: ja - model: oel_tagging.tag - pk: 76 + pk: -76 fields: taxonomy: 1 parent: null value: Javanese external_id: jv - model: oel_tagging.tag - pk: 77 + pk: -77 fields: taxonomy: 1 parent: null value: Georgian external_id: ka - model: oel_tagging.tag - pk: 78 + pk: -78 fields: taxonomy: 1 parent: null value: Kongo external_id: kg - model: oel_tagging.tag - pk: 79 + pk: -79 fields: taxonomy: 1 parent: null value: Kikuyu external_id: ki - model: oel_tagging.tag - pk: 80 + pk: -80 fields: taxonomy: 1 parent: null value: Kuanyama external_id: kj - model: oel_tagging.tag - pk: 81 + pk: -81 fields: taxonomy: 1 parent: null value: Kazakh external_id: kk - model: oel_tagging.tag - pk: 82 + pk: -82 fields: taxonomy: 1 parent: null value: Kalaallisut external_id: kl - model: oel_tagging.tag - pk: 83 + pk: -83 fields: taxonomy: 1 parent: null value: Central Khmer external_id: km - model: oel_tagging.tag - pk: 84 + pk: -84 fields: taxonomy: 1 parent: null value: Kannada external_id: kn - model: oel_tagging.tag - pk: 85 + pk: -85 fields: taxonomy: 1 parent: null value: Korean external_id: ko - model: oel_tagging.tag - pk: 86 + pk: -86 fields: taxonomy: 1 parent: null value: Kanuri external_id: kr - model: oel_tagging.tag - pk: 87 + pk: -87 fields: taxonomy: 1 parent: null value: Kashmiri external_id: ks - model: oel_tagging.tag - pk: 88 + pk: -88 fields: taxonomy: 1 parent: null value: Kurdish external_id: ku - model: oel_tagging.tag - pk: 89 + pk: -89 fields: taxonomy: 1 parent: null value: Komi external_id: kv - model: oel_tagging.tag - pk: 90 + pk: -90 fields: taxonomy: 1 parent: null value: Cornish external_id: kw - model: oel_tagging.tag - pk: 91 + pk: -91 fields: taxonomy: 1 parent: null value: Kirghiz external_id: ky - model: oel_tagging.tag - pk: 92 + pk: -92 fields: taxonomy: 1 parent: null value: Latin external_id: la - model: oel_tagging.tag - pk: 93 + pk: -93 fields: taxonomy: 1 parent: null value: Luxembourgish external_id: lb - model: oel_tagging.tag - pk: 94 + pk: -94 fields: taxonomy: 1 parent: null value: Ganda external_id: lg - model: oel_tagging.tag - pk: 95 + pk: -95 fields: taxonomy: 1 parent: null value: Limburgan external_id: li - model: oel_tagging.tag - pk: 96 + pk: -96 fields: taxonomy: 1 parent: null value: Lingala external_id: ln - model: oel_tagging.tag - pk: 97 + pk: -97 fields: taxonomy: 1 parent: null value: Lao external_id: lo - model: oel_tagging.tag - pk: 98 + pk: -98 fields: taxonomy: 1 parent: null value: Lithuanian external_id: lt - model: oel_tagging.tag - pk: 99 + pk: -99 fields: taxonomy: 1 parent: null value: Luba-Katanga external_id: lu - model: oel_tagging.tag - pk: 100 + pk: -100 fields: taxonomy: 1 parent: null value: Latvian external_id: lv - model: oel_tagging.tag - pk: 101 + pk: -101 fields: taxonomy: 1 parent: null value: Malagasy external_id: mg - model: oel_tagging.tag - pk: 102 + pk: -102 fields: taxonomy: 1 parent: null value: Marshallese external_id: mh - model: oel_tagging.tag - pk: 103 + pk: -103 fields: taxonomy: 1 parent: null value: Maori external_id: mi - model: oel_tagging.tag - pk: 104 + pk: -104 fields: taxonomy: 1 parent: null value: Macedonian external_id: mk - model: oel_tagging.tag - pk: 105 + pk: -105 fields: taxonomy: 1 parent: null value: Malayalam external_id: ml - model: oel_tagging.tag - pk: 106 + pk: -106 fields: taxonomy: 1 parent: null value: Mongolian external_id: mn - model: oel_tagging.tag - pk: 107 + pk: -107 fields: taxonomy: 1 parent: null value: Marathi external_id: mr - model: oel_tagging.tag - pk: 108 + pk: -108 fields: taxonomy: 1 parent: null value: Malay external_id: ms - model: oel_tagging.tag - pk: 109 + pk: -109 fields: taxonomy: 1 parent: null value: Maltese external_id: mt - model: oel_tagging.tag - pk: 110 + pk: -110 fields: taxonomy: 1 parent: null value: Burmese external_id: my - model: oel_tagging.tag - pk: 111 + pk: -111 fields: taxonomy: 1 parent: null value: Nauru external_id: na - model: oel_tagging.tag - pk: 112 + pk: -112 fields: taxonomy: 1 parent: null value: Bokmål, Norwegian external_id: nb - model: oel_tagging.tag - pk: 113 + pk: -113 fields: taxonomy: 1 parent: null value: Ndebele, North external_id: nd - model: oel_tagging.tag - pk: 114 + pk: -114 fields: taxonomy: 1 parent: null value: Nepali external_id: ne - model: oel_tagging.tag - pk: 115 + pk: -115 fields: taxonomy: 1 parent: null value: Ndonga external_id: ng - model: oel_tagging.tag - pk: 116 + pk: -116 fields: taxonomy: 1 parent: null value: Dutch external_id: nl - model: oel_tagging.tag - pk: 117 + pk: -117 fields: taxonomy: 1 parent: null value: Norwegian Nynorsk external_id: nn - model: oel_tagging.tag - pk: 118 + pk: -118 fields: taxonomy: 1 parent: null value: Norwegian external_id: no - model: oel_tagging.tag - pk: 119 + pk: -119 fields: taxonomy: 1 parent: null value: Ndebele, South external_id: nr - model: oel_tagging.tag - pk: 120 + pk: -120 fields: taxonomy: 1 parent: null value: Navajo external_id: nv - model: oel_tagging.tag - pk: 121 + pk: -121 fields: taxonomy: 1 parent: null value: Chichewa external_id: ny - model: oel_tagging.tag - pk: 122 + pk: -122 fields: taxonomy: 1 parent: null value: Occitan (post 1500) external_id: oc - model: oel_tagging.tag - pk: 123 + pk: -123 fields: taxonomy: 1 parent: null value: Ojibwa external_id: oj - model: oel_tagging.tag - pk: 124 + pk: -124 fields: taxonomy: 1 parent: null value: Oromo external_id: om - model: oel_tagging.tag - pk: 125 + pk: -125 fields: taxonomy: 1 parent: null value: Oriya external_id: or - model: oel_tagging.tag - pk: 126 + pk: -126 fields: taxonomy: 1 parent: null value: Ossetian external_id: os - model: oel_tagging.tag - pk: 127 + pk: -127 fields: taxonomy: 1 parent: null value: Panjabi external_id: pa - model: oel_tagging.tag - pk: 128 + pk: -128 fields: taxonomy: 1 parent: null value: Pali external_id: pi - model: oel_tagging.tag - pk: 129 + pk: -129 fields: taxonomy: 1 parent: null value: Polish external_id: pl - model: oel_tagging.tag - pk: 130 + pk: -130 fields: taxonomy: 1 parent: null value: Pushto external_id: ps - model: oel_tagging.tag - pk: 131 + pk: -131 fields: taxonomy: 1 parent: null value: Portuguese external_id: pt - model: oel_tagging.tag - pk: 132 + pk: -132 fields: taxonomy: 1 parent: null value: Quechua external_id: qu - model: oel_tagging.tag - pk: 133 + pk: -133 fields: taxonomy: 1 parent: null value: Romansh external_id: rm - model: oel_tagging.tag - pk: 134 + pk: -134 fields: taxonomy: 1 parent: null value: Rundi external_id: rn - model: oel_tagging.tag - pk: 135 + pk: -135 fields: taxonomy: 1 parent: null value: Romanian external_id: ro - model: oel_tagging.tag - pk: 136 + pk: -136 fields: taxonomy: 1 parent: null value: Russian external_id: ru - model: oel_tagging.tag - pk: 137 + pk: -137 fields: taxonomy: 1 parent: null value: Kinyarwanda external_id: rw - model: oel_tagging.tag - pk: 138 + pk: -138 fields: taxonomy: 1 parent: null value: Sanskrit external_id: sa - model: oel_tagging.tag - pk: 139 + pk: -139 fields: taxonomy: 1 parent: null value: Sardinian external_id: sc - model: oel_tagging.tag - pk: 140 + pk: -140 fields: taxonomy: 1 parent: null value: Sindhi external_id: sd - model: oel_tagging.tag - pk: 141 + pk: -141 fields: taxonomy: 1 parent: null value: Northern Sami external_id: se - model: oel_tagging.tag - pk: 142 + pk: -142 fields: taxonomy: 1 parent: null value: Sango external_id: sg - model: oel_tagging.tag - pk: 143 + pk: -143 fields: taxonomy: 1 parent: null value: Sinhala external_id: si - model: oel_tagging.tag - pk: 144 + pk: -144 fields: taxonomy: 1 parent: null value: Slovak external_id: sk - model: oel_tagging.tag - pk: 145 + pk: -145 fields: taxonomy: 1 parent: null value: Slovenian external_id: sl - model: oel_tagging.tag - pk: 146 + pk: -146 fields: taxonomy: 1 parent: null value: Samoan external_id: sm - model: oel_tagging.tag - pk: 147 + pk: -147 fields: taxonomy: 1 parent: null value: Shona external_id: sn - model: oel_tagging.tag - pk: 148 + pk: -148 fields: taxonomy: 1 parent: null value: Somali external_id: so - model: oel_tagging.tag - pk: 149 + pk: -149 fields: taxonomy: 1 parent: null value: Albanian external_id: sq - model: oel_tagging.tag - pk: 150 + pk: -150 fields: taxonomy: 1 parent: null value: Serbian external_id: sr - model: oel_tagging.tag - pk: 151 + pk: -151 fields: taxonomy: 1 parent: null value: Swati external_id: ss - model: oel_tagging.tag - pk: 152 + pk: -152 fields: taxonomy: 1 parent: null value: Sotho, Southern external_id: st - model: oel_tagging.tag - pk: 153 + pk: -153 fields: taxonomy: 1 parent: null value: Sundanese external_id: su - model: oel_tagging.tag - pk: 154 + pk: -154 fields: taxonomy: 1 parent: null value: Swedish external_id: sv - model: oel_tagging.tag - pk: 155 + pk: -155 fields: taxonomy: 1 parent: null value: Swahili external_id: sw - model: oel_tagging.tag - pk: 156 + pk: -156 fields: taxonomy: 1 parent: null value: Tamil external_id: ta - model: oel_tagging.tag - pk: 157 + pk: -157 fields: taxonomy: 1 parent: null value: Telugu external_id: te - model: oel_tagging.tag - pk: 158 + pk: -158 fields: taxonomy: 1 parent: null value: Tajik external_id: tg - model: oel_tagging.tag - pk: 159 + pk: -159 fields: taxonomy: 1 parent: null value: Thai external_id: th - model: oel_tagging.tag - pk: 160 + pk: -160 fields: taxonomy: 1 parent: null value: Tigrinya external_id: ti - model: oel_tagging.tag - pk: 161 + pk: -161 fields: taxonomy: 1 parent: null value: Turkmen external_id: tk - model: oel_tagging.tag - pk: 162 + pk: -162 fields: taxonomy: 1 parent: null value: Tagalog external_id: tl - model: oel_tagging.tag - pk: 163 + pk: -163 fields: taxonomy: 1 parent: null value: Tswana external_id: tn - model: oel_tagging.tag - pk: 164 + pk: -164 fields: taxonomy: 1 parent: null value: Tonga (Tonga Islands) external_id: to - model: oel_tagging.tag - pk: 165 + pk: -165 fields: taxonomy: 1 parent: null value: Turkish external_id: tr - model: oel_tagging.tag - pk: 166 + pk: -166 fields: taxonomy: 1 parent: null value: Tsonga external_id: ts - model: oel_tagging.tag - pk: 167 + pk: -167 fields: taxonomy: 1 parent: null value: Tatar external_id: tt - model: oel_tagging.tag - pk: 168 + pk: -168 fields: taxonomy: 1 parent: null value: Twi external_id: tw - model: oel_tagging.tag - pk: 169 + pk: -169 fields: taxonomy: 1 parent: null value: Tahitian external_id: ty - model: oel_tagging.tag - pk: 170 + pk: -170 fields: taxonomy: 1 parent: null value: Uighur external_id: ug - model: oel_tagging.tag - pk: 171 + pk: -171 fields: taxonomy: 1 parent: null value: Ukrainian external_id: uk - model: oel_tagging.tag - pk: 172 + pk: -172 fields: taxonomy: 1 parent: null value: Urdu external_id: ur - model: oel_tagging.tag - pk: 173 + pk: -173 fields: taxonomy: 1 parent: null value: Uzbek external_id: uz - model: oel_tagging.tag - pk: 174 + pk: -174 fields: taxonomy: 1 parent: null value: Venda external_id: ve - model: oel_tagging.tag - pk: 175 + pk: -175 fields: taxonomy: 1 parent: null value: Vietnamese external_id: vi - model: oel_tagging.tag - pk: 176 + pk: -176 fields: taxonomy: 1 parent: null value: Volapük external_id: vo - model: oel_tagging.tag - pk: 177 + pk: -177 fields: taxonomy: 1 parent: null value: Walloon external_id: wa - model: oel_tagging.tag - pk: 178 + pk: -178 fields: taxonomy: 1 parent: null value: Wolof external_id: wo - model: oel_tagging.tag - pk: 179 + pk: -179 fields: taxonomy: 1 parent: null value: Xhosa external_id: xh - model: oel_tagging.tag - pk: 180 + pk: -180 fields: taxonomy: 1 parent: null value: Yiddish external_id: yi - model: oel_tagging.tag - pk: 181 + pk: -181 fields: taxonomy: 1 parent: null value: Yoruba external_id: yo - model: oel_tagging.tag - pk: 182 + pk: -182 fields: taxonomy: 1 parent: null value: Zhuang external_id: za - model: oel_tagging.tag - pk: 183 + pk: -183 fields: taxonomy: 1 parent: null value: Chinese external_id: zh - model: oel_tagging.tag - pk: 184 + pk: -184 fields: taxonomy: 1 parent: null diff --git a/openedx_tagging/core/tagging/fixtures/system_defined.yaml b/openedx_tagging/core/tagging/fixtures/system_defined.yaml index c5737b64..6c085152 100644 --- a/openedx_tagging/core/tagging/fixtures/system_defined.yaml +++ b/openedx_tagging/core/tagging/fixtures/system_defined.yaml @@ -1,5 +1,5 @@ - model: oel_tagging.taxonomy - pk: 1 + pk: -1 fields: name: Languages description: ISO 639-1 Languages. Allows tags for any language configured for use on the instance diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py index 5735ac16..ed54bff9 100644 --- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -25,7 +25,7 @@ def download_json(self): return json.loads(json_data) def build_fixture(self, json_data): - tag_pk = 1 + tag_pk = -1 with open(output, "w") as output_file: for lang_data in json_data: lang_value = self.get_lang_value(lang_data) @@ -37,7 +37,8 @@ def build_fixture(self, json_data): output_file.write(" parent: null\n") output_file.write(f" value: {lang_value}\n") output_file.write(f" external_id: {lang_code}\n") - tag_pk += 1 + # System tags are identified with negative numbers to avoid clashing with user-created tags. + tag_pk -= 1 def get_lang_value(self, lang_data): """ From 2a0b4b6c931a1e2f418216b96f346ae1df896a96 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 20 Jul 2023 02:35:58 +0930 Subject: [PATCH 13/20] refactor: use ObjectTag subclasses where possible * Adds Taxonomy.object_tag_class property, which can be overridden by Taxonomy subclasses to provide their own ObjectTag subclasses. * Replaces the Taxonomy._set_tag method with an ObjectTag.tag_ref property setter, so that ObjectTag subclasses can dynamically create Tags as needed during tag_object() * Moves the tag_class_model and tag_class_value properties to a new ModelObjectTag abstract class, so the ModelSystemDefinedTaxonomy can be simpler. * Updates tests for coverage --- openedx_tagging/core/tagging/api.py | 3 +- .../tagging/migrations/0003_system_defined.py | 22 +- openedx_tagging/core/tagging/models.py | 258 +++++++++++------- .../core/tagging/test_models.py | 39 ++- 4 files changed, 213 insertions(+), 109 deletions(-) diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 5ca23c48..5d8ca115 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -106,8 +106,9 @@ def get_object_tags( Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too. Invalid tags will (probably) be hidden from learners. """ + ObjectTagClass = taxonomy.object_tag_class if taxonomy else ObjectTag tags = ( - ObjectTag.objects.filter( + ObjectTagClass.objects.filter( object_id=object_id, ) .select_related("tag", "taxonomy") diff --git a/openedx_tagging/core/tagging/migrations/0003_system_defined.py b/openedx_tagging/core/tagging/migrations/0003_system_defined.py index 99637a49..8a8bf1d4 100644 --- a/openedx_tagging/core/tagging/migrations/0003_system_defined.py +++ b/openedx_tagging/core/tagging/migrations/0003_system_defined.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.19 on 2023-07-19 20:28 +# Generated by Django 3.2.19 on 2023-07-19 17:03 from django.db import migrations @@ -9,6 +9,16 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name="ModelObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.objecttag",), + ), migrations.CreateModel( name="SystemDefinedTaxonomy", fields=[], @@ -39,6 +49,16 @@ class Migration(migrations.Migration): }, bases=("oel_tagging.systemdefinedtaxonomy",), ), + migrations.CreateModel( + name="UserModelObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.modelobjecttag",), + ), migrations.CreateModel( name="UserSystemDefinedTaxonomy", fields=[], diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index 0f0494d6..30031d13 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -181,6 +181,15 @@ def __str__(self): """ return f"<{self.__class__.__name__}> ({self.id}) {self.name}" + @property + def object_tag_class(self) -> Type: + """ + Returns the ObjectTag subclass associated with this taxonomy, which is ObjectTag by default. + + Taxonomy subclasses may override this method to use different subclasses of ObjectTag. + """ + return ObjectTag + @property def taxonomy_class(self) -> Type: """ @@ -353,28 +362,6 @@ def _check_object( """ return bool(object_tag.object_id) - def _set_tag( - self, - tag_ref, - object_tag: "ObjectTag", - ) -> "ObjectTag": - """ - Set a tag on `object_tag` and run a resync - - Subclasses can override this method to perform their own set tag process. - """ - try: - object_tag.tag = self.tag_set.get( - id=tag_ref, - ) - except (ValueError, Tag.DoesNotExist): - # This might be ok, e.g. if self.allow_free_text. - # We'll validate below before saving. - object_tag.value = tag_ref - - object_tag.resync() - return object_tag - def tag_object( self, tags: List, @@ -396,9 +383,10 @@ def tag_object( _(f"Taxonomy ({self.id}) requires at least one tag per object.") ) + ObjectTagClass = self.object_tag_class current_tags = { tag.tag_ref: tag - for tag in ObjectTag.objects.filter( + for tag in ObjectTagClass.objects.filter( taxonomy=self, object_id=object_id, ) @@ -408,14 +396,14 @@ def tag_object( if tag_ref in current_tags: object_tag = current_tags.pop(tag_ref) else: - object_tag = ObjectTag( + object_tag = ObjectTagClass( taxonomy=self, object_id=object_id, ) - object_tag = self._set_tag(tag_ref, object_tag) - - if not object_tag or not self.validate_object_tag(object_tag): + object_tag.tag_ref = tag_ref + object_tag.resync() + if not self.validate_object_tag(object_tag): raise ValueError( _(f"Invalid object tag for taxonomy ({self.id}): {tag_ref}") ) @@ -553,6 +541,25 @@ def tag_ref(self) -> str: """ return self.tag.id if self.tag_id else self._value + @tag_ref.setter + def tag_ref(self, tag_ref: str): + """ + Sets the ObjectTag's Tag and/or value, depending on whether a valid Tag is found. + + Subclasses may override this method to dynamically create Tags. + """ + self.value = tag_ref + + if self.taxonomy_id: + try: + self.tag = self.taxonomy.tag_set.get(pk=tag_ref) + self.value = self.tag.value + return + except (ValueError, Tag.DoesNotExist): + # This might be ok, e.g. if our taxonomy.allow_free_text, so we just pass through here. + # We rely on the caller to validate before saving. + pass + def is_valid(self) -> bool: """ Returns True if this ObjectTag represents a valid taxonomy tag. @@ -652,6 +659,10 @@ def copy(self, object_tag: "ObjectTag") -> "ObjectTag": class SystemDefinedTaxonomy(Taxonomy): + """ + Simple subclass of Taxonomy which requires the system_defined flag to be set. + """ + class Meta: proxy = True @@ -662,21 +673,11 @@ def _check_taxonomy(self, object_tag: ObjectTag) -> bool: return super()._check_taxonomy(object_tag) and self.system_defined -class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): +class ModelObjectTag(ObjectTag): """ - Model based system taxonomy abstract class. - - This type of taxonomies has an associated Django model in `tag_class_model()`. - Are designed to create a Tag when an ObjectTags with a new Tag. - The Tags are representations of the instances of the associated model. + Model-based ObjectTag, abstract class. - On Tag.external_id stores an identifier from the instance (`pk` as default) - and on Tag.value stores an human readable representation of the instance - (e.g. `username`). - The subclasses can override this behavior, to choose the right field. - - When an ObjectTag is created with an existing Tag, - the Tag is re-synchronized with its instance. + Used by ModelSystemDefinedTaxonomy to maintain dynamic Tags which are associated with a configured Model instance. """ class Meta: @@ -697,93 +698,119 @@ class referenced by these object tags. """ raise NotImplementedError - def _check_instance(self, object_tag: ObjectTag) -> bool: + @property + def tag_class_value(self) -> str: """ - Returns True if the instance exists + Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. - Subclasses can override this method to perform their own instance validation checks. + Subclasses may override this method to use different fields. """ - intance_id = object_tag.tag.external_id - return self.tag_class_model.objects.filter(pk=intance_id).exists() + return "pk" - def _check_tag(self, object_tag: ObjectTag) -> bool: + def get_instance(self) -> Union[models.Model, None]: """ - Returns True if pass the instance validation checks + Returns the instance of tag_class_model associated with this object tag, or None if not found. """ - return super()._check_tag(object_tag) and self._check_instance(object_tag) + instance_id = self.tag.external_id if self.tag else None + if instance_id: + try: + return self.tag_class_model.objects.get(pk=instance_id) + except self.tag_class_model.DoesNotExist: + log.exception( + f"{self}: {self.tag_class_model.__name__} pk={instance_id} does not exist." + ) - def _get_external_id_from_instance(self, instance) -> str: - """ - Returns the tag external id from the instance + return None - Subclasses can override this method to get the external id from their own models. + def _resync_tag(self) -> bool: """ - return str(instance.pk) + Resync our tag's value with the value from the instance. - def _get_value_from_instance(self, instance) -> str: - """ - Returns the tag value from the instance + If the instance associated with the tag no longer exists, we unset our tag, because it's no longer valid. - Subclasses can override this method to get the value from their own models. + Returns True if the given tag was changed, False otherwise. """ - return str(instance.pk) + instance = self.get_instance() + if instance: + value = getattr(instance, self.tag_class_value) + self.value = value + if self.tag and self.tag.value != value: + self.tag.value = value + self.tag.save() + return True + else: + self.tag = None + + return False - def _get_instance(self, pk): + @property + def tag_ref(self) -> str: + return (self.tag.external_id or self.tag.id) if self.tag_id else self._value + + @tag_ref.setter + def tag_ref(self, tag_ref: str): """ - Gets the instance from `pk` + Sets the ObjectTag's Tag and/or value, depending on whether a valid Tag is found, or can be created. + + Creates a Tag for the given tag_ref value, if one containing that external_id not already exist. """ - try: - return self.tag_class_model.objects.get(pk=pk) - except self.tag_class_model.DoesNotExist: - return None + self.value = tag_ref - def _resync_tag(self, tag: Tag) -> Tag: + if self.taxonomy_id: + try: + self.tag = self.taxonomy.tag_set.get( + external_id=tag_ref, + ) + except (ValueError, Tag.DoesNotExist): + # Creates a new Tag for this instance + self.tag = Tag( + taxonomy=self.taxonomy, + external_id=tag_ref, + ) + + self._resync_tag() + + +class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): + """ + Model based system taxonomy abstract class. + + This type of taxonomies has an associated Django model in `tag_class_model()`. + Are designed to create a Tag when an ObjectTags with a new Tag. + The Tags are representations of the instances of the associated model. + + On Tag.external_id stores an identifier from the instance (`pk` as default) + and on Tag.value stores an human readable representation of the instance + (e.g. `username`). + The subclasses can override this behavior, to choose the right field. + + When an ObjectTag is created with an existing Tag, + the Tag is re-synchronized with its instance. + """ + + class Meta: + proxy = True + + def __init__(self, *args: Any, **kwargs: Any) -> None: """ - Resync the tag value with the value from the instance + Checks if the `object_tag_class` is a subclass of ModelObjectTag. """ - instance = self._get_instance(tag.external_id) - if instance: - value = self._get_value_from_instance(instance) - if tag.value != value: - tag.value = value - tag.save() - return tag + assert issubclass(self.object_tag_class, ModelObjectTag) + super().__init__(*args, **kwargs) - def _set_tag(self, tag_ref, object_tag: ObjectTag) -> ObjectTag: + @property + def object_tag_class(self) -> Type: """ - Set or create the respective Tag on `object_tag` + Returns the ObjectTag subclass associated with this taxonomy. - This function is used on `tag_object`. - For this to work it is necessary that `tag_ref` - is always the `pk` of the model to which you want - to associate it. + Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. """ - try: - tag = self.tag_set.get( - external_id=tag_ref, - ) - # Run a resync of the tag - tag = self._resync_tag(tag) - object_tag.tag = tag - except (ValueError, Tag.DoesNotExist): - # Creates a new tag with the instance - instance = self._get_instance(tag_ref) - if not instance: - return None - new_tag = Tag( - taxonomy=self, - value=self._get_value_from_instance(instance), - external_id=self._get_external_id_from_instance(instance), - ) - new_tag.save() - object_tag.tag = new_tag - - return object_tag + raise NotImplementedError -class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): +class UserModelObjectTag(ModelObjectTag): """ - User based system taxonomy class. + ObjectTags for the UserSystemDefinedTaxonomy. """ class Meta: @@ -796,11 +823,32 @@ def tag_class_model(self) -> Type: """ return get_user_model() - def _get_value_from_instance(self, instance) -> str: + @property + def tag_class_value(self) -> str: + """ + Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. + + Subclasses may override this method to use different fields. """ - Returns the username as tag value + return "username" + + +class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): + """ + User based system taxonomy class. + """ + + class Meta: + proxy = True + + @property + def object_tag_class(self) -> Type: + """ + Returns the ObjectTag subclass associated with this taxonomy, which is ModelObjectTag by default. + + Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. """ - return instance.get_username() + return UserModelObjectTag class LanguageTaxonomy(SystemDefinedTaxonomy): diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index dadeb5f7..e5c65b70 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -5,6 +5,7 @@ from django.test.testcases import TestCase, override_settings from openedx_tagging.core.tagging.models import ( + ModelObjectTag, ModelSystemDefinedTaxonomy, ObjectTag, SystemDefinedTaxonomy, @@ -34,7 +35,7 @@ class InvalidModelTaxonomy(ModelSystemDefinedTaxonomy): """ @property - def tag_class_model(self): + def object_tag_class(self): return EmptyTestClass class Meta: @@ -43,7 +44,7 @@ class Meta: app_label = "oel_tagging" -class TestModelTaxonomy(ModelSystemDefinedTaxonomy): +class TestModelTag(ModelObjectTag): """ Model used for testing """ @@ -58,6 +59,21 @@ class Meta: app_label = "oel_tagging" +class TestModelTaxonomy(ModelSystemDefinedTaxonomy): + """ + Model used for testing + """ + + @property + def object_tag_class(self): + return TestModelTag + + class Meta: + proxy = True + managed = False + app_label = "oel_tagging" + + def get_tag(value): """ Fetches and returns the tag with the given value. @@ -360,6 +376,13 @@ def test_object_tag_lineage(self): object_tag.refresh_from_db() assert object_tag.get_lineage() == ["Another tag"] + def test_tag_ref(self): + object_tag = ObjectTag() + object_tag.tag_ref = 1 + object_tag.save() + assert object_tag.tag is None + assert object_tag.value == 1 + def test_object_tag_is_valid(self): open_taxonomy = Taxonomy.objects.create( name="Freetext Life", @@ -509,6 +532,7 @@ class TestModelSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): @ddt.data( (ModelSystemDefinedTaxonomy, NotImplementedError), + (ModelObjectTag, NotImplementedError), (InvalidModelTaxonomy, AssertionError), (UserSystemDefinedTaxonomy, None), ) @@ -611,6 +635,17 @@ def test_tag_object_delete_user(self): object_id="object_id", ) + def test_tag_ref(self): + object_tag = TestModelTag() + object_tag.tag_ref = 1 + object_tag.save() + assert object_tag.tag is None + assert object_tag.value == 1 + + def test_get_instance(self): + object_tag = TestModelTag() + assert object_tag.get_instance() is None + @ddt.ddt @override_settings(LANGUAGES=test_languages) From de3cc3e746b43abedec323a7b4a711dcdb0bdab7 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 20 Jul 2023 02:40:42 +0930 Subject: [PATCH 14/20] refactor: LanguageTaxonomy overrides get_tags removing the need for _get_tags_query_set --- openedx_tagging/core/tagging/models.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index 30031d13..3e891566 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -255,13 +255,7 @@ def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": self._taxonomy_class = taxonomy._taxonomy_class return self - def _get_tags_query_set(self) -> models.QuerySet: - """ - Base query set for used on `get_tags()` - """ - return self.tag_set - - def get_tags(self) -> List[Tag]: + def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]: """ Returns a list of all Tags in the current taxonomy, from the root(s) down to TAXONOMY_MAX_DEPTH tags, in tree order. @@ -273,9 +267,12 @@ def get_tags(self) -> List[Tag]: if self.allow_free_text: return tags + if tag_set is None: + tag_set = self.tag_set + parents = None for depth in range(TAXONOMY_MAX_DEPTH): - filtered_tags = self._get_tags_query_set().prefetch_related("parent") + filtered_tags = tag_set.prefetch_related("parent") if parents is None: filtered_tags = filtered_tags.filter(parent=None) else: @@ -862,12 +859,13 @@ class LanguageTaxonomy(SystemDefinedTaxonomy): class Meta: proxy = True - def _get_tags_query_set(self) -> models.QuerySet: + def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]: """ - Returns a query set of available languages tags. + Returns a list of all the available Language Tags, annotated with ``depth`` = 0. """ available_langs = self._get_available_languages() - return self.tag_set.filter(external_id__in=available_langs) + tag_set = self.tag_set.filter(external_id__in=available_langs) + return super().get_tags(tag_set=tag_set) def _get_available_languages(cls) -> List[str]: """ From 437ae98f18d2cf352beff49dab2c4335d9e04c3e Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 20 Jul 2023 12:06:52 -0500 Subject: [PATCH 15/20] docs: System taxonomy creation doc updated with Dynamic tags approach --- .../0012-system-taxonomy-creation.rst | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/decisions/0012-system-taxonomy-creation.rst b/docs/decisions/0012-system-taxonomy-creation.rst index 2ed17886..0036432b 100644 --- a/docs/decisions/0012-system-taxonomy-creation.rst +++ b/docs/decisions/0012-system-taxonomy-creation.rst @@ -18,7 +18,8 @@ System Tag lists and validation Each System-defined Taxonomy will have its own ``ObjectTag`` subclass which is used for tag validation (e.g. ``LanguageObjectTag``, ``OrganizationObjectTag``). Each subclass can overwrite ``get_tags``; to configure the valid tags, and ``is_valid``; to check if a list of tags are valid. Both functions are implemented on the ``ObjectTag`` base class, but can be overwritten to handle special cases. -We need to create an instance of each System-defined Taxonomy's ObjectTag in a fixture. This instances will be used on different APIs. +We need to create an instance of each System-defined Taxonomy in a fixture. With their respective characteristics and subclasses. +The ``pk`` of these instances must be negative so as not to affect the auto-incremented ``pk`` of Taxonomies. Later, we need to create content-side ObjectTags that live on ``openedx.features.content_tagging`` for each content and taxonomy to be used (eg. ``CourseLanguageObjectTag``, ``CourseOrganizationObjectTag``). This new class is used to configure the automatic content tagging. You can read the `document number 0013`_ to see this configuration. @@ -30,26 +31,28 @@ We have two ways to handle Tags creation and validation for System-defined Taxon **Hardcoded by fixtures/migrations** -#. If the tags don't change over the time, you can create all on a fixture (e.g Languages). +#. If the tags don't change over the time, you can create all on a fixture (e.g Languages). + The ``pk`` of these instances must be negative. #. If the tags change over the time, you can create all on a migration. If you edit, delete, or add new tags, you should also do it in a migration. -**Free-form tags** +**Dynamic tags** -This taxonomy depends on a core data model, but simplifies the creation of Tags by allowing free-form tags, -but we can validate the tags using the ``validate_object_tag`` method. For example we can put the ``AuthorSystemTaxonomy`` associated with -the ``User`` model and use the ``ID`` field as tags. Also we can validate if an ``User`` still exists or has been deleted over time. +Closed Taxonomies that depends on a core data model. Ex. AuthorTaxonomy with Users as Tags + +#. Tags are created on the fly when new ObjectTags are added. +#. Tag.external_id we store an identifier from the instance (eg. User.pk). +#. Tag.value we store a human readable representation of the instance (eg. User.username). +#. Resync the tags to re-fetch the value. Rejected Options ----------------- -Tags created by Auto-generated from the codebase +Free-form tags ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Taxonomies that depend on a core data model could create a Tag for each eligible object. -Maintaining this dynamic list of available Tags is cumbersome: we'd need triggers for creation, editing, and deletion. -And if it's a large list of objects (e.g. Users), then copying that list into the Tag table is overkill. -It is better to dynamically generate the list of available Tags, and/or dynamically validate a submitted object tag than -to store the options in the database. +Open Taxonomy that depends on a core data model, but simplifies the creation of Tags by allowing free-form tags, + +Rejected because it has been seen that using dynamic tags provides more functionality and more advantages. .. _document number 0013: https://github.com/openedx/openedx-learning/blob/main/docs/decisions/0013-system-taxonomy-auto-tagging.rst From 8ca16eab7ccdcf256fb368b10d3a05ee1a059b83 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 20 Jul 2023 12:38:38 -0500 Subject: [PATCH 16/20] style: Updating comments --- openedx_tagging/core/tagging/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index 3e891566..703fb0bd 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -772,12 +772,13 @@ class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): """ Model based system taxonomy abstract class. - This type of taxonomies has an associated Django model in `tag_class_model()`. - Are designed to create a Tag when an ObjectTags with a new Tag. + This type of taxonomy has an associated Django model in `tag_class_model()`. + They are designed to create Tags when required for new ObjectTags, to maintain + their status as "closed" taxonomies. The Tags are representations of the instances of the associated model. - On Tag.external_id stores an identifier from the instance (`pk` as default) - and on Tag.value stores an human readable representation of the instance + Tag.external_id stores an identifier from the instance (`pk` as default) + and Tag.value stores a human readable representation of the instance (e.g. `username`). The subclasses can override this behavior, to choose the right field. From 0c74bb4e2d86d66abad38158d508f6655c71dda5 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 20 Jul 2023 13:15:52 -0500 Subject: [PATCH 17/20] style: Separating the models into base and system defined --- .../core/tagging/models/__init__.py | 10 + .../tagging/{models.py => models/base.py} | 243 +--------------- .../core/tagging/models/system_defined.py | 248 ++++++++++++++++ .../core/tagging/test_models.py | 259 +---------------- .../tagging/test_system_defined_models.py | 268 ++++++++++++++++++ 5 files changed, 530 insertions(+), 498 deletions(-) create mode 100644 openedx_tagging/core/tagging/models/__init__.py rename openedx_tagging/core/tagging/{models.py => models/base.py} (74%) create mode 100644 openedx_tagging/core/tagging/models/system_defined.py create mode 100644 tests/openedx_tagging/core/tagging/test_system_defined_models.py diff --git a/openedx_tagging/core/tagging/models/__init__.py b/openedx_tagging/core/tagging/models/__init__.py new file mode 100644 index 00000000..3dd7ff10 --- /dev/null +++ b/openedx_tagging/core/tagging/models/__init__.py @@ -0,0 +1,10 @@ +from .base import ( + Tag, + Taxonomy, + ObjectTag, +) + +from .system_defined import ( + UserSystemDefinedTaxonomy, + LanguageTaxonomy +) diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models/base.py similarity index 74% rename from openedx_tagging/core/tagging/models.py rename to openedx_tagging/core/tagging/models/base.py index 703fb0bd..0c52caa1 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -1,9 +1,7 @@ -""" Tagging app data models """ +""" Tagging app base data models """ import logging -from typing import Any, List, Type, Union +from typing import List, Type, Union -from django.conf import settings -from django.contrib.auth import get_user_model from django.db import models from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ @@ -653,240 +651,3 @@ def copy(self, object_tag: "ObjectTag") -> "ObjectTag": self._value = object_tag._value self._name = object_tag._name return self - - -class SystemDefinedTaxonomy(Taxonomy): - """ - Simple subclass of Taxonomy which requires the system_defined flag to be set. - """ - - class Meta: - proxy = True - - def _check_taxonomy(self, object_tag: ObjectTag) -> bool: - """ - Returns True if this is a system defined taxonomy - """ - return super()._check_taxonomy(object_tag) and self.system_defined - - -class ModelObjectTag(ObjectTag): - """ - Model-based ObjectTag, abstract class. - - Used by ModelSystemDefinedTaxonomy to maintain dynamic Tags which are associated with a configured Model instance. - """ - - class Meta: - proxy = True - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """ - Checks if the `tag_class_model` is correct - """ - assert issubclass(self.tag_class_model, models.Model) - super().__init__(*args, **kwargs) - - @property - def tag_class_model(self) -> Type: - """ - Subclasses must implement this method to return the Django.model - class referenced by these object tags. - """ - raise NotImplementedError - - @property - def tag_class_value(self) -> str: - """ - Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. - - Subclasses may override this method to use different fields. - """ - return "pk" - - def get_instance(self) -> Union[models.Model, None]: - """ - Returns the instance of tag_class_model associated with this object tag, or None if not found. - """ - instance_id = self.tag.external_id if self.tag else None - if instance_id: - try: - return self.tag_class_model.objects.get(pk=instance_id) - except self.tag_class_model.DoesNotExist: - log.exception( - f"{self}: {self.tag_class_model.__name__} pk={instance_id} does not exist." - ) - - return None - - def _resync_tag(self) -> bool: - """ - Resync our tag's value with the value from the instance. - - If the instance associated with the tag no longer exists, we unset our tag, because it's no longer valid. - - Returns True if the given tag was changed, False otherwise. - """ - instance = self.get_instance() - if instance: - value = getattr(instance, self.tag_class_value) - self.value = value - if self.tag and self.tag.value != value: - self.tag.value = value - self.tag.save() - return True - else: - self.tag = None - - return False - - @property - def tag_ref(self) -> str: - return (self.tag.external_id or self.tag.id) if self.tag_id else self._value - - @tag_ref.setter - def tag_ref(self, tag_ref: str): - """ - Sets the ObjectTag's Tag and/or value, depending on whether a valid Tag is found, or can be created. - - Creates a Tag for the given tag_ref value, if one containing that external_id not already exist. - """ - self.value = tag_ref - - if self.taxonomy_id: - try: - self.tag = self.taxonomy.tag_set.get( - external_id=tag_ref, - ) - except (ValueError, Tag.DoesNotExist): - # Creates a new Tag for this instance - self.tag = Tag( - taxonomy=self.taxonomy, - external_id=tag_ref, - ) - - self._resync_tag() - - -class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): - """ - Model based system taxonomy abstract class. - - This type of taxonomy has an associated Django model in `tag_class_model()`. - They are designed to create Tags when required for new ObjectTags, to maintain - their status as "closed" taxonomies. - The Tags are representations of the instances of the associated model. - - Tag.external_id stores an identifier from the instance (`pk` as default) - and Tag.value stores a human readable representation of the instance - (e.g. `username`). - The subclasses can override this behavior, to choose the right field. - - When an ObjectTag is created with an existing Tag, - the Tag is re-synchronized with its instance. - """ - - class Meta: - proxy = True - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """ - Checks if the `object_tag_class` is a subclass of ModelObjectTag. - """ - assert issubclass(self.object_tag_class, ModelObjectTag) - super().__init__(*args, **kwargs) - - @property - def object_tag_class(self) -> Type: - """ - Returns the ObjectTag subclass associated with this taxonomy. - - Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. - """ - raise NotImplementedError - - -class UserModelObjectTag(ModelObjectTag): - """ - ObjectTags for the UserSystemDefinedTaxonomy. - """ - - class Meta: - proxy = True - - @property - def tag_class_model(self) -> Type: - """ - Associate the user model - """ - return get_user_model() - - @property - def tag_class_value(self) -> str: - """ - Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. - - Subclasses may override this method to use different fields. - """ - return "username" - - -class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): - """ - User based system taxonomy class. - """ - - class Meta: - proxy = True - - @property - def object_tag_class(self) -> Type: - """ - Returns the ObjectTag subclass associated with this taxonomy, which is ModelObjectTag by default. - - Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. - """ - return UserModelObjectTag - - -class LanguageTaxonomy(SystemDefinedTaxonomy): - """ - Language System-defined taxonomy - - The tags are filtered and validated taking into account the - languages available in Django LANGUAGES settings var - """ - - class Meta: - proxy = True - - def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]: - """ - Returns a list of all the available Language Tags, annotated with ``depth`` = 0. - """ - available_langs = self._get_available_languages() - tag_set = self.tag_set.filter(external_id__in=available_langs) - return super().get_tags(tag_set=tag_set) - - def _get_available_languages(cls) -> List[str]: - """ - Get available languages from Django LANGUAGE. - """ - langs = set() - for django_lang in settings.LANGUAGES: - # Split to get the language part - langs.add(django_lang[0].split("-")[0]) - return langs - - def _check_valid_language(self, object_tag: ObjectTag) -> bool: - """ - Returns True if the tag is on the available languages - """ - available_langs = self._get_available_languages() - return object_tag.tag.external_id in available_langs - - def _check_tag(self, object_tag: ObjectTag) -> bool: - """ - Returns True if the tag is on the available languages - """ - return super()._check_tag(object_tag) and self._check_valid_language(object_tag) diff --git a/openedx_tagging/core/tagging/models/system_defined.py b/openedx_tagging/core/tagging/models/system_defined.py new file mode 100644 index 00000000..adb4059b --- /dev/null +++ b/openedx_tagging/core/tagging/models/system_defined.py @@ -0,0 +1,248 @@ +""" Tagging app system-defined taxonomies data models """ +import logging +from typing import Any, List, Type, Union + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import models + +from .base import Tag, Taxonomy, ObjectTag + +log = logging.getLogger(__name__) + + +class SystemDefinedTaxonomy(Taxonomy): + """ + Simple subclass of Taxonomy which requires the system_defined flag to be set. + """ + + class Meta: + proxy = True + + def _check_taxonomy(self, object_tag: ObjectTag) -> bool: + """ + Returns True if this is a system defined taxonomy + """ + return super()._check_taxonomy(object_tag) and self.system_defined + + +class ModelObjectTag(ObjectTag): + """ + Model-based ObjectTag, abstract class. + + Used by ModelSystemDefinedTaxonomy to maintain dynamic Tags which are associated with a configured Model instance. + """ + + class Meta: + proxy = True + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Checks if the `tag_class_model` is correct + """ + assert issubclass(self.tag_class_model, models.Model) + super().__init__(*args, **kwargs) + + @property + def tag_class_model(self) -> Type: + """ + Subclasses must implement this method to return the Django.model + class referenced by these object tags. + """ + raise NotImplementedError + + @property + def tag_class_value(self) -> str: + """ + Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. + + Subclasses may override this method to use different fields. + """ + return "pk" + + def get_instance(self) -> Union[models.Model, None]: + """ + Returns the instance of tag_class_model associated with this object tag, or None if not found. + """ + instance_id = self.tag.external_id if self.tag else None + if instance_id: + try: + return self.tag_class_model.objects.get(pk=instance_id) + except self.tag_class_model.DoesNotExist: + log.exception( + f"{self}: {self.tag_class_model.__name__} pk={instance_id} does not exist." + ) + + return None + + def _resync_tag(self) -> bool: + """ + Resync our tag's value with the value from the instance. + + If the instance associated with the tag no longer exists, we unset our tag, because it's no longer valid. + + Returns True if the given tag was changed, False otherwise. + """ + instance = self.get_instance() + if instance: + value = getattr(instance, self.tag_class_value) + self.value = value + if self.tag and self.tag.value != value: + self.tag.value = value + self.tag.save() + return True + else: + self.tag = None + + return False + + @property + def tag_ref(self) -> str: + return (self.tag.external_id or self.tag.id) if self.tag_id else self._value + + @tag_ref.setter + def tag_ref(self, tag_ref: str): + """ + Sets the ObjectTag's Tag and/or value, depending on whether a valid Tag is found, or can be created. + + Creates a Tag for the given tag_ref value, if one containing that external_id not already exist. + """ + self.value = tag_ref + + if self.taxonomy_id: + try: + self.tag = self.taxonomy.tag_set.get( + external_id=tag_ref, + ) + except (ValueError, Tag.DoesNotExist): + # Creates a new Tag for this instance + self.tag = Tag( + taxonomy=self.taxonomy, + external_id=tag_ref, + ) + + self._resync_tag() + + +class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): + """ + Model based system taxonomy abstract class. + + This type of taxonomy has an associated Django model in `tag_class_model()`. + They are designed to create Tags when required for new ObjectTags, to maintain + their status as "closed" taxonomies. + The Tags are representations of the instances of the associated model. + + Tag.external_id stores an identifier from the instance (`pk` as default) + and Tag.value stores a human readable representation of the instance + (e.g. `username`). + The subclasses can override this behavior, to choose the right field. + + When an ObjectTag is created with an existing Tag, + the Tag is re-synchronized with its instance. + """ + + class Meta: + proxy = True + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Checks if the `object_tag_class` is a subclass of ModelObjectTag. + """ + assert issubclass(self.object_tag_class, ModelObjectTag) + super().__init__(*args, **kwargs) + + @property + def object_tag_class(self) -> Type: + """ + Returns the ObjectTag subclass associated with this taxonomy. + + Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. + """ + raise NotImplementedError + + +class UserModelObjectTag(ModelObjectTag): + """ + ObjectTags for the UserSystemDefinedTaxonomy. + """ + + class Meta: + proxy = True + + @property + def tag_class_model(self) -> Type: + """ + Associate the user model + """ + return get_user_model() + + @property + def tag_class_value(self) -> str: + """ + Returns the name of the tag_class_model field to use as the Tag.value when creating Tags for this taxonomy. + + Subclasses may override this method to use different fields. + """ + return "username" + + +class UserSystemDefinedTaxonomy(ModelSystemDefinedTaxonomy): + """ + User based system taxonomy class. + """ + + class Meta: + proxy = True + + @property + def object_tag_class(self) -> Type: + """ + Returns the ObjectTag subclass associated with this taxonomy, which is ModelObjectTag by default. + + Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. + """ + return UserModelObjectTag + + +class LanguageTaxonomy(SystemDefinedTaxonomy): + """ + Language System-defined taxonomy + + The tags are filtered and validated taking into account the + languages available in Django LANGUAGES settings var + """ + + class Meta: + proxy = True + + def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]: + """ + Returns a list of all the available Language Tags, annotated with ``depth`` = 0. + """ + available_langs = self._get_available_languages() + tag_set = self.tag_set.filter(external_id__in=available_langs) + return super().get_tags(tag_set=tag_set) + + def _get_available_languages(cls) -> List[str]: + """ + Get available languages from Django LANGUAGE. + """ + langs = set() + for django_lang in settings.LANGUAGES: + # Split to get the language part + langs.add(django_lang[0].split("-")[0]) + return langs + + def _check_valid_language(self, object_tag: ObjectTag) -> bool: + """ + Returns True if the tag is on the available languages + """ + available_langs = self._get_available_languages() + return object_tag.tag.external_id in available_langs + + def _check_tag(self, object_tag: ObjectTag) -> bool: + """ + Returns True if the tag is on the available languages + """ + return super()._check_tag(object_tag) and self._check_valid_language(object_tag) diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index e5c65b70..8d84bf0b 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -1,78 +1,14 @@ -""" Test the tagging models """ - +""" Test the tagging base models """ import ddt from django.contrib.auth import get_user_model -from django.test.testcases import TestCase, override_settings +from django.test.testcases import TestCase from openedx_tagging.core.tagging.models import ( - ModelObjectTag, - ModelSystemDefinedTaxonomy, ObjectTag, - SystemDefinedTaxonomy, Tag, Taxonomy, - UserSystemDefinedTaxonomy, ) -test_languages = [ - ("en", "English"), - ("az", "Azerbaijani"), - ("id", "Indonesian"), - ("qu", "Quechua"), - ("zu", "Zulu"), -] - - -class EmptyTestClass: - """ - Empty class used for testing - """ - - -class InvalidModelTaxonomy(ModelSystemDefinedTaxonomy): - """ - Model used for testing - """ - - @property - def object_tag_class(self): - return EmptyTestClass - - class Meta: - proxy = True - managed = False - app_label = "oel_tagging" - - -class TestModelTag(ModelObjectTag): - """ - Model used for testing - """ - - @property - def tag_class_model(self): - return get_user_model() - - class Meta: - proxy = True - managed = False - app_label = "oel_tagging" - - -class TestModelTaxonomy(ModelSystemDefinedTaxonomy): - """ - Model used for testing - """ - - @property - def object_tag_class(self): - return TestModelTag - - class Meta: - proxy = True - managed = False - app_label = "oel_tagging" - def get_tag(value): """ @@ -492,194 +428,3 @@ def test_tag_object_invalid_tag(self): "biology101", ) assert "Invalid object tag for taxonomy" in str(exc.exception) - - -@ddt.ddt -class TestSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): - """ - Test for System defined Taxonomy - """ - - @ddt.data( - ("system_taxonomy", "system_taxonomy", True), # Valid - ("taxonomy", "taxonomy", False), # Not a system defined - ("system_taxonomy", "taxonomy", False), # Testing parent validations - ) - @ddt.unpack - def test_validations(self, taxonomy, second_taxonomy, expected): - taxonomy = getattr(self, taxonomy) - taxonomy.taxonomy_class = SystemDefinedTaxonomy - taxonomy = taxonomy.cast() - - second_taxonomy = getattr(self, second_taxonomy) - - assert ( - taxonomy.validate_object_tag( - object_tag=ObjectTag(object_id="id", taxonomy=second_taxonomy), - check_taxonomy=True, - check_tag=False, - check_object=False, - ) - == expected - ) - - -@ddt.ddt -class TestModelSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): - """ - Test for Model Model System defined taxonomy - """ - - @ddt.data( - (ModelSystemDefinedTaxonomy, NotImplementedError), - (ModelObjectTag, NotImplementedError), - (InvalidModelTaxonomy, AssertionError), - (UserSystemDefinedTaxonomy, None), - ) - @ddt.unpack - def test_implementation_error(self, taxonomy_cls, expected_exception): - if not expected_exception: - assert taxonomy_cls() - else: - with self.assertRaises(expected_exception): - taxonomy_cls() - - @ddt.data( - (1, "tag_id"), # Valid - (0, "tag_id"), # Invalid user - (1, None), # Testing parent validations - ) - @ddt.unpack - def test_validations(self, tag_external_id, tag_id): - tag = Tag( - id=tag_id, - taxonomy=self.user_taxonomy, - value="_val", - external_id=tag_external_id, - ) - object_tag = ObjectTag( - object_id="id", - tag=tag, - ) - - self.user_taxonomy.validate_object_tag( - object_tag=object_tag, - check_object=False, - check_taxonomy=False, - check_tag=True, - ) - - def test_tag_object_invalid_user(self): - # Test user that doesn't exist - with self.assertRaises(ValueError): - self.user_taxonomy.tag_object(tags=[4], object_id="object_id") - - def _tag_object(self): - return self.user_taxonomy.tag_object( - tags=[self.user_1.id], object_id="object_id" - ) - - def test_tag_object_tag_creation(self): - # Test creation of a new Tag with user taxonomy - assert self.user_taxonomy.tag_set.count() == 0 - updated_tags = self._tag_object() - assert self.user_taxonomy.tag_set.count() == 1 - assert len(updated_tags) == 1 - assert updated_tags[0].tag.external_id == str(self.user_1.id) - assert updated_tags[0].tag.value == self.user_1.get_username() - - # Test parent functions - taxonomy = TestModelTaxonomy( - name="Test", - description="Test", - system_defined=True, - ) - taxonomy.save() - assert taxonomy.tag_set.count() == 0 - updated_tags = taxonomy.tag_object(tags=[self.user_1.id], object_id="object_id") - assert taxonomy.tag_set.count() == 1 - assert taxonomy.tag_set.count() == 1 - assert len(updated_tags) == 1 - assert updated_tags[0].tag.external_id == str(self.user_1.id) - assert updated_tags[0].tag.value == str(self.user_1.id) - - def test_tag_object_existing_tag(self): - # Test add an existing Tag - self._tag_object() - assert self.user_taxonomy.tag_set.count() == 1 - updated_tags = self._tag_object() - assert self.user_taxonomy.tag_set.count() == 1 - assert len(updated_tags) == 1 - assert updated_tags[0].tag.external_id == str(self.user_1.id) - assert updated_tags[0].tag.value == self.user_1.get_username() - - def test_tag_object_resync(self): - self._tag_object() - - self.user_1.username = "new_username" - self.user_1.save() - updated_tags = self._tag_object() - assert self.user_taxonomy.tag_set.count() == 1 - assert len(updated_tags) == 1 - assert updated_tags[0].tag.external_id == str(self.user_1.id) - assert updated_tags[0].tag.value == self.user_1.get_username() - - def test_tag_object_delete_user(self): - # Test after delete user - self._tag_object() - user_1_id = self.user_1.id - self.user_1.delete() - with self.assertRaises(ValueError): - self.user_taxonomy.tag_object( - tags=[user_1_id], - object_id="object_id", - ) - - def test_tag_ref(self): - object_tag = TestModelTag() - object_tag.tag_ref = 1 - object_tag.save() - assert object_tag.tag is None - assert object_tag.value == 1 - - def test_get_instance(self): - object_tag = TestModelTag() - assert object_tag.get_instance() is None - - -@ddt.ddt -@override_settings(LANGUAGES=test_languages) -class TestLanguageTaxonomy(TestTagTaxonomyMixin, TestCase): - """ - Test for Language taxonomy - """ - - @ddt.data( - ("en", "tag_id"), # Valid - ("es", "tag_id"), # Not available lang - ("en", None), # Test parent validations - ) - @ddt.unpack - def test_validations(self, lang, tag_id): - tag = Tag( - id=tag_id, - taxonomy=self.language_taxonomy, - value="_val", - external_id=lang, - ) - object_tag = ObjectTag( - object_id="id", - tag=tag, - ) - self.language_taxonomy.validate_object_tag( - object_tag=object_tag, - check_object=False, - check_taxonomy=False, - check_tag=True, - ) - - def test_get_tags(self): - tags = self.language_taxonomy.get_tags() - for tag in tags: - assert tag.external_id in test_languages - assert tag.annotated_field == 0 diff --git a/tests/openedx_tagging/core/tagging/test_system_defined_models.py b/tests/openedx_tagging/core/tagging/test_system_defined_models.py new file mode 100644 index 00000000..80baf888 --- /dev/null +++ b/tests/openedx_tagging/core/tagging/test_system_defined_models.py @@ -0,0 +1,268 @@ +""" Test the tagging system-defined taxonomy models """ +import ddt + +from django.test.testcases import TestCase, override_settings +from django.contrib.auth import get_user_model + +from openedx_tagging.core.tagging.models import ( + ObjectTag, + Tag, +) +from openedx_tagging.core.tagging.models.system_defined import ( + ModelObjectTag, + ModelSystemDefinedTaxonomy, + SystemDefinedTaxonomy, + UserSystemDefinedTaxonomy, +) + +from .test_models import TestTagTaxonomyMixin + + +test_languages = [ + ("en", "English"), + ("az", "Azerbaijani"), + ("id", "Indonesian"), + ("qu", "Quechua"), + ("zu", "Zulu"), +] + + +class EmptyTestClass: + """ + Empty class used for testing + """ + + +class InvalidModelTaxonomy(ModelSystemDefinedTaxonomy): + """ + Model used for testing + """ + + @property + def object_tag_class(self): + return EmptyTestClass + + class Meta: + proxy = True + managed = False + app_label = "oel_tagging" + + +class TestModelTag(ModelObjectTag): + """ + Model used for testing + """ + + @property + def tag_class_model(self): + return get_user_model() + + class Meta: + proxy = True + managed = False + app_label = "oel_tagging" + + +class TestModelTaxonomy(ModelSystemDefinedTaxonomy): + """ + Model used for testing + """ + + @property + def object_tag_class(self): + return TestModelTag + + class Meta: + proxy = True + managed = False + app_label = "oel_tagging" + +@ddt.ddt +class TestSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for System defined Taxonomy + """ + + @ddt.data( + ("system_taxonomy", "system_taxonomy", True), # Valid + ("taxonomy", "taxonomy", False), # Not a system defined + ("system_taxonomy", "taxonomy", False), # Testing parent validations + ) + @ddt.unpack + def test_validations(self, taxonomy, second_taxonomy, expected): + taxonomy = getattr(self, taxonomy) + taxonomy.taxonomy_class = SystemDefinedTaxonomy + taxonomy = taxonomy.cast() + + second_taxonomy = getattr(self, second_taxonomy) + + assert ( + taxonomy.validate_object_tag( + object_tag=ObjectTag(object_id="id", taxonomy=second_taxonomy), + check_taxonomy=True, + check_tag=False, + check_object=False, + ) + == expected + ) + + +@ddt.ddt +class TestModelSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for Model Model System defined taxonomy + """ + + @ddt.data( + (ModelSystemDefinedTaxonomy, NotImplementedError), + (ModelObjectTag, NotImplementedError), + (InvalidModelTaxonomy, AssertionError), + (UserSystemDefinedTaxonomy, None), + ) + @ddt.unpack + def test_implementation_error(self, taxonomy_cls, expected_exception): + if not expected_exception: + assert taxonomy_cls() + else: + with self.assertRaises(expected_exception): + taxonomy_cls() + + @ddt.data( + (1, "tag_id"), # Valid + (0, "tag_id"), # Invalid user + (1, None), # Testing parent validations + ) + @ddt.unpack + def test_validations(self, tag_external_id, tag_id): + tag = Tag( + id=tag_id, + taxonomy=self.user_taxonomy, + value="_val", + external_id=tag_external_id, + ) + object_tag = ObjectTag( + object_id="id", + tag=tag, + ) + + self.user_taxonomy.validate_object_tag( + object_tag=object_tag, + check_object=False, + check_taxonomy=False, + check_tag=True, + ) + + def test_tag_object_invalid_user(self): + # Test user that doesn't exist + with self.assertRaises(ValueError): + self.user_taxonomy.tag_object(tags=[4], object_id="object_id") + + def _tag_object(self): + return self.user_taxonomy.tag_object( + tags=[self.user_1.id], object_id="object_id" + ) + + def test_tag_object_tag_creation(self): + # Test creation of a new Tag with user taxonomy + assert self.user_taxonomy.tag_set.count() == 0 + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + # Test parent functions + taxonomy = TestModelTaxonomy( + name="Test", + description="Test", + system_defined=True, + ) + taxonomy.save() + assert taxonomy.tag_set.count() == 0 + updated_tags = taxonomy.tag_object(tags=[self.user_1.id], object_id="object_id") + assert taxonomy.tag_set.count() == 1 + assert taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == str(self.user_1.id) + + def test_tag_object_existing_tag(self): + # Test add an existing Tag + self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + def test_tag_object_resync(self): + self._tag_object() + + self.user_1.username = "new_username" + self.user_1.save() + updated_tags = self._tag_object() + assert self.user_taxonomy.tag_set.count() == 1 + assert len(updated_tags) == 1 + assert updated_tags[0].tag.external_id == str(self.user_1.id) + assert updated_tags[0].tag.value == self.user_1.get_username() + + def test_tag_object_delete_user(self): + # Test after delete user + self._tag_object() + user_1_id = self.user_1.id + self.user_1.delete() + with self.assertRaises(ValueError): + self.user_taxonomy.tag_object( + tags=[user_1_id], + object_id="object_id", + ) + + def test_tag_ref(self): + object_tag = TestModelTag() + object_tag.tag_ref = 1 + object_tag.save() + assert object_tag.tag is None + assert object_tag.value == 1 + + def test_get_instance(self): + object_tag = TestModelTag() + assert object_tag.get_instance() is None + + +@ddt.ddt +@override_settings(LANGUAGES=test_languages) +class TestLanguageTaxonomy(TestTagTaxonomyMixin, TestCase): + """ + Test for Language taxonomy + """ + + @ddt.data( + ("en", "tag_id"), # Valid + ("es", "tag_id"), # Not available lang + ("en", None), # Test parent validations + ) + @ddt.unpack + def test_validations(self, lang, tag_id): + tag = Tag( + id=tag_id, + taxonomy=self.language_taxonomy, + value="_val", + external_id=lang, + ) + object_tag = ObjectTag( + object_id="id", + tag=tag, + ) + self.language_taxonomy.validate_object_tag( + object_tag=object_tag, + check_object=False, + check_taxonomy=False, + check_tag=True, + ) + + def test_get_tags(self): + tags = self.language_taxonomy.get_tags() + for tag in tags: + assert tag.external_id in test_languages + assert tag.annotated_field == 0 \ No newline at end of file From 9a380476c50d35457ee5df1404fedc34cecc0332 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 21 Jul 2023 10:53:21 -0500 Subject: [PATCH 18/20] fix: Update language fixture to negative pk --- .../tagging/fixtures/language_taxonomy.yaml | 368 +++++++++--------- .../commands/build_language_fixture.py | 3 +- 2 files changed, 186 insertions(+), 185 deletions(-) diff --git a/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml index 06d51e15..ac2c934e 100644 --- a/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml +++ b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml @@ -1,1288 +1,1288 @@ - model: oel_tagging.tag pk: -1 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Afar external_id: aa - model: oel_tagging.tag pk: -2 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Abkhazian external_id: ab - model: oel_tagging.tag pk: -3 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Avestan external_id: ae - model: oel_tagging.tag pk: -4 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Afrikaans external_id: af - model: oel_tagging.tag pk: -5 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Akan external_id: ak - model: oel_tagging.tag pk: -6 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Amharic external_id: am - model: oel_tagging.tag pk: -7 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Aragonese external_id: an - model: oel_tagging.tag pk: -8 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Arabic external_id: ar - model: oel_tagging.tag pk: -9 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Assamese external_id: as - model: oel_tagging.tag pk: -10 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Avaric external_id: av - model: oel_tagging.tag pk: -11 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Aymara external_id: ay - model: oel_tagging.tag pk: -12 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Azerbaijani external_id: az - model: oel_tagging.tag pk: -13 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bashkir external_id: ba - model: oel_tagging.tag pk: -14 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Belarusian external_id: be - model: oel_tagging.tag pk: -15 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bulgarian external_id: bg - model: oel_tagging.tag pk: -16 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bihari languages external_id: bh - model: oel_tagging.tag pk: -17 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bislama external_id: bi - model: oel_tagging.tag pk: -18 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bambara external_id: bm - model: oel_tagging.tag pk: -19 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bengali external_id: bn - model: oel_tagging.tag pk: -20 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tibetan external_id: bo - model: oel_tagging.tag pk: -21 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Breton external_id: br - model: oel_tagging.tag pk: -22 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bosnian external_id: bs - model: oel_tagging.tag pk: -23 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Catalan external_id: ca - model: oel_tagging.tag pk: -24 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Chechen external_id: ce - model: oel_tagging.tag pk: -25 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Chamorro external_id: ch - model: oel_tagging.tag pk: -26 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Corsican external_id: co - model: oel_tagging.tag pk: -27 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Cree external_id: cr - model: oel_tagging.tag pk: -28 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Czech external_id: cs - model: oel_tagging.tag pk: -29 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Church Slavic external_id: cu - model: oel_tagging.tag pk: -30 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Chuvash external_id: cv - model: oel_tagging.tag pk: -31 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Welsh external_id: cy - model: oel_tagging.tag pk: -32 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Danish external_id: da - model: oel_tagging.tag pk: -33 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: German external_id: de - model: oel_tagging.tag pk: -34 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Divehi external_id: dv - model: oel_tagging.tag pk: -35 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Dzongkha external_id: dz - model: oel_tagging.tag pk: -36 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ewe external_id: ee - model: oel_tagging.tag pk: -37 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Greek, Modern (1453-) external_id: el - model: oel_tagging.tag pk: -38 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: English external_id: en - model: oel_tagging.tag pk: -39 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Esperanto external_id: eo - model: oel_tagging.tag pk: -40 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Spanish external_id: es - model: oel_tagging.tag pk: -41 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Estonian external_id: et - model: oel_tagging.tag pk: -42 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Basque external_id: eu - model: oel_tagging.tag pk: -43 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Persian external_id: fa - model: oel_tagging.tag pk: -44 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Fulah external_id: ff - model: oel_tagging.tag pk: -45 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Finnish external_id: fi - model: oel_tagging.tag pk: -46 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Fijian external_id: fj - model: oel_tagging.tag pk: -47 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Faroese external_id: fo - model: oel_tagging.tag pk: -48 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: French external_id: fr - model: oel_tagging.tag pk: -49 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Western Frisian external_id: fy - model: oel_tagging.tag pk: -50 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Irish external_id: ga - model: oel_tagging.tag pk: -51 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Gaelic external_id: gd - model: oel_tagging.tag pk: -52 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Galician external_id: gl - model: oel_tagging.tag pk: -53 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Guarani external_id: gn - model: oel_tagging.tag pk: -54 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Gujarati external_id: gu - model: oel_tagging.tag pk: -55 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Manx external_id: gv - model: oel_tagging.tag pk: -56 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Hausa external_id: ha - model: oel_tagging.tag pk: -57 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Hebrew external_id: he - model: oel_tagging.tag pk: -58 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Hindi external_id: hi - model: oel_tagging.tag pk: -59 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Hiri Motu external_id: ho - model: oel_tagging.tag pk: -60 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Croatian external_id: hr - model: oel_tagging.tag pk: -61 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Haitian external_id: ht - model: oel_tagging.tag pk: -62 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Hungarian external_id: hu - model: oel_tagging.tag pk: -63 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Armenian external_id: hy - model: oel_tagging.tag pk: -64 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Herero external_id: hz - model: oel_tagging.tag pk: -65 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Interlingua (International Auxiliary Language Association) external_id: ia - model: oel_tagging.tag pk: -66 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Indonesian external_id: id - model: oel_tagging.tag pk: -67 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Interlingue external_id: ie - model: oel_tagging.tag pk: -68 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Igbo external_id: ig - model: oel_tagging.tag pk: -69 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sichuan Yi external_id: ii - model: oel_tagging.tag pk: -70 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Inupiaq external_id: ik - model: oel_tagging.tag pk: -71 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ido external_id: io - model: oel_tagging.tag pk: -72 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Icelandic external_id: is - model: oel_tagging.tag pk: -73 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Italian external_id: it - model: oel_tagging.tag pk: -74 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Inuktitut external_id: iu - model: oel_tagging.tag pk: -75 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Japanese external_id: ja - model: oel_tagging.tag pk: -76 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Javanese external_id: jv - model: oel_tagging.tag pk: -77 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Georgian external_id: ka - model: oel_tagging.tag pk: -78 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kongo external_id: kg - model: oel_tagging.tag pk: -79 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kikuyu external_id: ki - model: oel_tagging.tag pk: -80 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kuanyama external_id: kj - model: oel_tagging.tag pk: -81 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kazakh external_id: kk - model: oel_tagging.tag pk: -82 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kalaallisut external_id: kl - model: oel_tagging.tag pk: -83 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Central Khmer external_id: km - model: oel_tagging.tag pk: -84 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kannada external_id: kn - model: oel_tagging.tag pk: -85 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Korean external_id: ko - model: oel_tagging.tag pk: -86 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kanuri external_id: kr - model: oel_tagging.tag pk: -87 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kashmiri external_id: ks - model: oel_tagging.tag pk: -88 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kurdish external_id: ku - model: oel_tagging.tag pk: -89 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Komi external_id: kv - model: oel_tagging.tag pk: -90 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Cornish external_id: kw - model: oel_tagging.tag pk: -91 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kirghiz external_id: ky - model: oel_tagging.tag pk: -92 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Latin external_id: la - model: oel_tagging.tag pk: -93 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Luxembourgish external_id: lb - model: oel_tagging.tag pk: -94 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ganda external_id: lg - model: oel_tagging.tag pk: -95 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Limburgan external_id: li - model: oel_tagging.tag pk: -96 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Lingala external_id: ln - model: oel_tagging.tag pk: -97 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Lao external_id: lo - model: oel_tagging.tag pk: -98 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Lithuanian external_id: lt - model: oel_tagging.tag pk: -99 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Luba-Katanga external_id: lu - model: oel_tagging.tag pk: -100 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Latvian external_id: lv - model: oel_tagging.tag pk: -101 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Malagasy external_id: mg - model: oel_tagging.tag pk: -102 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Marshallese external_id: mh - model: oel_tagging.tag pk: -103 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Maori external_id: mi - model: oel_tagging.tag pk: -104 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Macedonian external_id: mk - model: oel_tagging.tag pk: -105 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Malayalam external_id: ml - model: oel_tagging.tag pk: -106 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Mongolian external_id: mn - model: oel_tagging.tag pk: -107 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Marathi external_id: mr - model: oel_tagging.tag pk: -108 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Malay external_id: ms - model: oel_tagging.tag pk: -109 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Maltese external_id: mt - model: oel_tagging.tag pk: -110 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Burmese external_id: my - model: oel_tagging.tag pk: -111 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Nauru external_id: na - model: oel_tagging.tag pk: -112 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Bokmål, Norwegian external_id: nb - model: oel_tagging.tag pk: -113 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ndebele, North external_id: nd - model: oel_tagging.tag pk: -114 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Nepali external_id: ne - model: oel_tagging.tag pk: -115 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ndonga external_id: ng - model: oel_tagging.tag pk: -116 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Dutch external_id: nl - model: oel_tagging.tag pk: -117 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Norwegian Nynorsk external_id: nn - model: oel_tagging.tag pk: -118 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Norwegian external_id: no - model: oel_tagging.tag pk: -119 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ndebele, South external_id: nr - model: oel_tagging.tag pk: -120 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Navajo external_id: nv - model: oel_tagging.tag pk: -121 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Chichewa external_id: ny - model: oel_tagging.tag pk: -122 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Occitan (post 1500) external_id: oc - model: oel_tagging.tag pk: -123 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ojibwa external_id: oj - model: oel_tagging.tag pk: -124 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Oromo external_id: om - model: oel_tagging.tag pk: -125 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Oriya external_id: or - model: oel_tagging.tag pk: -126 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ossetian external_id: os - model: oel_tagging.tag pk: -127 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Panjabi external_id: pa - model: oel_tagging.tag pk: -128 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Pali external_id: pi - model: oel_tagging.tag pk: -129 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Polish external_id: pl - model: oel_tagging.tag pk: -130 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Pushto external_id: ps - model: oel_tagging.tag pk: -131 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Portuguese external_id: pt - model: oel_tagging.tag pk: -132 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Quechua external_id: qu - model: oel_tagging.tag pk: -133 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Romansh external_id: rm - model: oel_tagging.tag pk: -134 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Rundi external_id: rn - model: oel_tagging.tag pk: -135 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Romanian external_id: ro - model: oel_tagging.tag pk: -136 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Russian external_id: ru - model: oel_tagging.tag pk: -137 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Kinyarwanda external_id: rw - model: oel_tagging.tag pk: -138 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sanskrit external_id: sa - model: oel_tagging.tag pk: -139 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sardinian external_id: sc - model: oel_tagging.tag pk: -140 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sindhi external_id: sd - model: oel_tagging.tag pk: -141 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Northern Sami external_id: se - model: oel_tagging.tag pk: -142 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sango external_id: sg - model: oel_tagging.tag pk: -143 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sinhala external_id: si - model: oel_tagging.tag pk: -144 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Slovak external_id: sk - model: oel_tagging.tag pk: -145 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Slovenian external_id: sl - model: oel_tagging.tag pk: -146 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Samoan external_id: sm - model: oel_tagging.tag pk: -147 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Shona external_id: sn - model: oel_tagging.tag pk: -148 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Somali external_id: so - model: oel_tagging.tag pk: -149 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Albanian external_id: sq - model: oel_tagging.tag pk: -150 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Serbian external_id: sr - model: oel_tagging.tag pk: -151 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Swati external_id: ss - model: oel_tagging.tag pk: -152 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sotho, Southern external_id: st - model: oel_tagging.tag pk: -153 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Sundanese external_id: su - model: oel_tagging.tag pk: -154 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Swedish external_id: sv - model: oel_tagging.tag pk: -155 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Swahili external_id: sw - model: oel_tagging.tag pk: -156 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tamil external_id: ta - model: oel_tagging.tag pk: -157 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Telugu external_id: te - model: oel_tagging.tag pk: -158 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tajik external_id: tg - model: oel_tagging.tag pk: -159 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Thai external_id: th - model: oel_tagging.tag pk: -160 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tigrinya external_id: ti - model: oel_tagging.tag pk: -161 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Turkmen external_id: tk - model: oel_tagging.tag pk: -162 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tagalog external_id: tl - model: oel_tagging.tag pk: -163 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tswana external_id: tn - model: oel_tagging.tag pk: -164 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tonga (Tonga Islands) external_id: to - model: oel_tagging.tag pk: -165 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Turkish external_id: tr - model: oel_tagging.tag pk: -166 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tsonga external_id: ts - model: oel_tagging.tag pk: -167 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tatar external_id: tt - model: oel_tagging.tag pk: -168 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Twi external_id: tw - model: oel_tagging.tag pk: -169 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Tahitian external_id: ty - model: oel_tagging.tag pk: -170 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Uighur external_id: ug - model: oel_tagging.tag pk: -171 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Ukrainian external_id: uk - model: oel_tagging.tag pk: -172 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Urdu external_id: ur - model: oel_tagging.tag pk: -173 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Uzbek external_id: uz - model: oel_tagging.tag pk: -174 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Venda external_id: ve - model: oel_tagging.tag pk: -175 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Vietnamese external_id: vi - model: oel_tagging.tag pk: -176 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Volapük external_id: vo - model: oel_tagging.tag pk: -177 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Walloon external_id: wa - model: oel_tagging.tag pk: -178 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Wolof external_id: wo - model: oel_tagging.tag pk: -179 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Xhosa external_id: xh - model: oel_tagging.tag pk: -180 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Yiddish external_id: yi - model: oel_tagging.tag pk: -181 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Yoruba external_id: yo - model: oel_tagging.tag pk: -182 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Zhuang external_id: za - model: oel_tagging.tag pk: -183 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Chinese external_id: zh - model: oel_tagging.tag pk: -184 fields: - taxonomy: 1 + taxonomy: -1 parent: null value: Zulu external_id: zu diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py index ed54bff9..2acd178f 100644 --- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -33,12 +33,13 @@ def build_fixture(self, json_data): output_file.write("- model: oel_tagging.tag\n") output_file.write(f" pk: {tag_pk}\n") output_file.write(" fields:\n") - output_file.write(" taxonomy: 1\n") + output_file.write(" taxonomy: -1\n") output_file.write(" parent: null\n") output_file.write(f" value: {lang_value}\n") output_file.write(f" external_id: {lang_code}\n") # System tags are identified with negative numbers to avoid clashing with user-created tags. tag_pk -= 1 + def get_lang_value(self, lang_data): """ From 1847248b48176014b989a25e03ec00b9dda5f26e Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 21 Jul 2023 12:39:23 -0500 Subject: [PATCH 19/20] feat: Updates on Taxonomy and Tag admins - Taxonomy.system_defined updated to be a hardcoded property. - TaxonomyAdmin updated to avoid edit and delete system taxonomies. - TagAdmin updated to avoid edit and delete tags from system taxonomies. - TagAmdin updated to avoid create Tags on system taxonomies. --- openedx_tagging/core/tagging/admin.py | 95 ++++++++++++++++++- .../core/tagging/fixtures/system_defined.yaml | 11 --- .../migrations/0003_auto_20230721_1238.py | 83 ++++++++++++++++ .../tagging/migrations/0003_system_defined.py | 72 -------------- openedx_tagging/core/tagging/models/base.py | 25 +++-- .../core/tagging/models/system_defined.py | 8 +- .../core/fixtures/tagging.yaml | 10 +- .../openedx_tagging/core/tagging/test_api.py | 2 +- .../tagging/test_system_defined_models.py | 33 +------ 9 files changed, 202 insertions(+), 137 deletions(-) delete mode 100644 openedx_tagging/core/tagging/fixtures/system_defined.yaml create mode 100644 openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py delete mode 100644 openedx_tagging/core/tagging/migrations/0003_system_defined.py diff --git a/openedx_tagging/core/tagging/admin.py b/openedx_tagging/core/tagging/admin.py index 91b1d753..39363af5 100644 --- a/openedx_tagging/core/tagging/admin.py +++ b/openedx_tagging/core/tagging/admin.py @@ -1,8 +1,99 @@ """ Tagging app admin """ +from django import forms from django.contrib import admin from .models import ObjectTag, Tag, Taxonomy -admin.site.register(Taxonomy) -admin.site.register(Tag) + +def check_taxonomy(taxonomy: Taxonomy): + """ + Checks if the taxonomy is valid to edit or delete + """ + taxonomy = taxonomy.cast() + return not taxonomy.system_defined + + +class TaxonomyAdmin(admin.ModelAdmin): + """ + Admin for Taxonomy Model + """ + + def has_change_permission(self, request, obj=None): + """ + Avoid edit system-defined taxonomies + """ + if obj is not None: + return check_taxonomy(taxonomy=obj) + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """ + Avoid delete system-defined taxonomies + """ + if obj is not None: + return check_taxonomy(taxonomy=obj) + return super().has_change_permission(request, obj) + + +class TagForm(forms.ModelForm): + """ + Form for create a Tag + """ + class Meta: + model = Tag + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + print(self.fields) + if 'taxonomy' in self.fields: + self.fields['taxonomy'].queryset = self._filter_taxonomies() + + def _filter_taxonomies(self): + """ + Returns taxonomies that allows Tag creation + + - Not allow free text + - Not system defined + """ + taxonomy_queryset = Taxonomy.objects.filter( + allow_free_text=False + ) + valid_taxonomy_ids = [ + taxonomy.id for taxonomy + in taxonomy_queryset if check_taxonomy(taxonomy) + ] + + return taxonomy_queryset.filter(id__in=valid_taxonomy_ids) + + +class TagAdmin(admin.ModelAdmin): + """ + Admin for Tag Model + """ + form = TagForm + + def has_change_permission(self, request, obj=None): + """ + Avoid edit system-defined taxonomies + """ + if obj is not None: + taxonomy = obj.taxonomy + if taxonomy: + return check_taxonomy(taxonomy) + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """ + Avoid delete system-defined taxonomies + """ + if obj is not None: + taxonomy = obj.taxonomy + if taxonomy: + return check_taxonomy(taxonomy) + return super().has_change_permission(request, obj) + + +admin.site.register(Taxonomy, TaxonomyAdmin) +admin.site.register(Tag, TagAdmin) admin.site.register(ObjectTag) diff --git a/openedx_tagging/core/tagging/fixtures/system_defined.yaml b/openedx_tagging/core/tagging/fixtures/system_defined.yaml deleted file mode 100644 index 6c085152..00000000 --- a/openedx_tagging/core/tagging/fixtures/system_defined.yaml +++ /dev/null @@ -1,11 +0,0 @@ -- model: oel_tagging.taxonomy - pk: -1 - fields: - name: Languages - description: ISO 639-1 Languages. Allows tags for any language configured for use on the instance - enabled: true - required: true - allow_multiple: false - allow_free_text: false - system_defined: true - visible_to_authors: true diff --git a/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py b/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py new file mode 100644 index 00000000..16920a1b --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py @@ -0,0 +1,83 @@ +# Generated by Django 3.2.19 on 2023-07-21 17:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0002_auto_20230718_2026'), + ] + + operations = [ + migrations.CreateModel( + name='ModelObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.objecttag',), + ), + migrations.CreateModel( + name='SystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.taxonomy',), + ), + migrations.RemoveField( + model_name='taxonomy', + name='system_defined', + ), + migrations.CreateModel( + name='LanguageTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.systemdefinedtaxonomy',), + ), + migrations.CreateModel( + name='ModelSystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.systemdefinedtaxonomy',), + ), + migrations.CreateModel( + name='UserModelObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.modelobjecttag',), + ), + migrations.CreateModel( + name='UserSystemDefinedTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.modelsystemdefinedtaxonomy',), + ), + ] diff --git a/openedx_tagging/core/tagging/migrations/0003_system_defined.py b/openedx_tagging/core/tagging/migrations/0003_system_defined.py deleted file mode 100644 index 8a8bf1d4..00000000 --- a/openedx_tagging/core/tagging/migrations/0003_system_defined.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 3.2.19 on 2023-07-19 17:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("oel_tagging", "0002_auto_20230718_2026"), - ] - - operations = [ - migrations.CreateModel( - name="ModelObjectTag", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.objecttag",), - ), - migrations.CreateModel( - name="SystemDefinedTaxonomy", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.taxonomy",), - ), - migrations.CreateModel( - name="LanguageTaxonomy", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.systemdefinedtaxonomy",), - ), - migrations.CreateModel( - name="ModelSystemDefinedTaxonomy", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.systemdefinedtaxonomy",), - ), - migrations.CreateModel( - name="UserModelObjectTag", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.modelobjecttag",), - ), - migrations.CreateModel( - name="UserSystemDefinedTaxonomy", - fields=[], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("oel_tagging.modelsystemdefinedtaxonomy",), - ), - ] diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index 0c52caa1..8af03796 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -140,14 +140,6 @@ class Taxonomy(models.Model): "Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values." ), ) - system_defined = models.BooleanField( - default=False, - editable=False, - help_text=_( - "Indicates that tags and metadata for this taxonomy are maintained by the system;" - " taxonomy admins will not be permitted to modify them.", - ), - ) visible_to_authors = models.BooleanField( default=True, editable=False, @@ -177,6 +169,14 @@ def __str__(self): """ User-facing string representation of a Taxonomy. """ + try: + if self._taxonomy_class: + return f"<{self.taxonomy_class.__name__}> ({self.id}) {self.name}" + except ImportError: + # Log error and continue + log.exception( + f"Unable to import taxonomy_class for {self.id}: {self._taxonomy_class}" + ) return f"<{self.__class__.__name__}> ({self.id}) {self.name}" @property @@ -198,6 +198,14 @@ def taxonomy_class(self) -> Type: if self._taxonomy_class: return import_string(self._taxonomy_class) return None + + @property + def system_defined(self) -> bool: + """ + Indicates that tags and metadata for this taxonomy are maintained by the system; + taxonomy admins will not be permitted to modify them. + """ + return False @taxonomy_class.setter def taxonomy_class(self, taxonomy_class: Union[Type, None]): @@ -248,7 +256,6 @@ def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": self.required = taxonomy.required self.allow_multiple = taxonomy.allow_multiple self.allow_free_text = taxonomy.allow_free_text - self.system_defined = taxonomy.system_defined self.visible_to_authors = taxonomy.visible_to_authors self._taxonomy_class = taxonomy._taxonomy_class return self diff --git a/openedx_tagging/core/tagging/models/system_defined.py b/openedx_tagging/core/tagging/models/system_defined.py index adb4059b..252a8b7d 100644 --- a/openedx_tagging/core/tagging/models/system_defined.py +++ b/openedx_tagging/core/tagging/models/system_defined.py @@ -19,11 +19,13 @@ class SystemDefinedTaxonomy(Taxonomy): class Meta: proxy = True - def _check_taxonomy(self, object_tag: ObjectTag) -> bool: + @property + def system_defined(self) -> bool: """ - Returns True if this is a system defined taxonomy + Indicates that tags and metadata for this taxonomy are maintained by the system; + taxonomy admins will not be permitted to modify them. """ - return super()._check_taxonomy(object_tag) and self.system_defined + return True class ModelObjectTag(ObjectTag): diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml index 90bca397..4633901a 100644 --- a/tests/openedx_tagging/core/fixtures/tagging.yaml +++ b/tests/openedx_tagging/core/fixtures/tagging.yaml @@ -161,7 +161,6 @@ required: false allow_multiple: false allow_free_text: false - system_defined: false - model: oel_tagging.taxonomy pk: 2 fields: @@ -171,8 +170,7 @@ required: false allow_multiple: false allow_free_text: false - system_defined: true - _taxonomy_class: openedx_tagging.core.tagging.models.LanguageTaxonomy + _taxonomy_class: openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy - model: oel_tagging.taxonomy pk: 3 fields: @@ -182,8 +180,7 @@ required: false allow_multiple: false allow_free_text: false - system_defined: true - _taxonomy_class: openedx_tagging.core.tagging.models.UserSystemDefinedTaxonomy + _taxonomy_class: openedx_tagging.core.tagging.models.system_defined.UserSystemDefinedTaxonomy - model: oel_tagging.taxonomy pk: 4 fields: @@ -193,5 +190,4 @@ required: false allow_multiple: false allow_free_text: false - system_defined: true - _taxonomy_class: openedx_tagging.core.tagging.models.SystemDefinedTaxonomy + _taxonomy_class: openedx_tagging.core.tagging.models.system_defined.SystemDefinedTaxonomy diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index 521c4814..654da846 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -57,7 +57,7 @@ def test_get_taxonomies(self): ] assert str(enabled[0]) == f" ({tax1.id}) Enabled" assert str(enabled[1]) == " (1) Life on Earth" - assert str(enabled[2]) == " (4) System defined taxonomy" + assert str(enabled[2]) == " (4) System defined taxonomy" with self.assertNumQueries(1): disabled = list(tagging_api.get_taxonomies(enabled=False)) diff --git a/tests/openedx_tagging/core/tagging/test_system_defined_models.py b/tests/openedx_tagging/core/tagging/test_system_defined_models.py index 80baf888..a361e01e 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined_models.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined_models.py @@ -11,7 +11,6 @@ from openedx_tagging.core.tagging.models.system_defined import ( ModelObjectTag, ModelSystemDefinedTaxonomy, - SystemDefinedTaxonomy, UserSystemDefinedTaxonomy, ) @@ -77,35 +76,6 @@ class Meta: managed = False app_label = "oel_tagging" -@ddt.ddt -class TestSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): - """ - Test for System defined Taxonomy - """ - - @ddt.data( - ("system_taxonomy", "system_taxonomy", True), # Valid - ("taxonomy", "taxonomy", False), # Not a system defined - ("system_taxonomy", "taxonomy", False), # Testing parent validations - ) - @ddt.unpack - def test_validations(self, taxonomy, second_taxonomy, expected): - taxonomy = getattr(self, taxonomy) - taxonomy.taxonomy_class = SystemDefinedTaxonomy - taxonomy = taxonomy.cast() - - second_taxonomy = getattr(self, second_taxonomy) - - assert ( - taxonomy.validate_object_tag( - object_tag=ObjectTag(object_id="id", taxonomy=second_taxonomy), - check_taxonomy=True, - check_tag=False, - check_object=False, - ) - == expected - ) - @ddt.ddt class TestModelSystemDefinedTaxonomy(TestTagTaxonomyMixin, TestCase): @@ -175,7 +145,6 @@ def test_tag_object_tag_creation(self): taxonomy = TestModelTaxonomy( name="Test", description="Test", - system_defined=True, ) taxonomy.save() assert taxonomy.tag_set.count() == 0 @@ -265,4 +234,4 @@ def test_get_tags(self): tags = self.language_taxonomy.get_tags() for tag in tags: assert tag.external_id in test_languages - assert tag.annotated_field == 0 \ No newline at end of file + assert tag.annotated_field == 0 From eaaba78d33cacc2caf17d708ecb0b82806c44da4 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 21 Jul 2023 15:03:38 -0500 Subject: [PATCH 20/20] feat: Instance validations on ModelSystemDefinedTaxonomy --- .../core/tagging/models/__init__.py | 5 +++-- .../core/tagging/models/system_defined.py | 19 ++++++++++++++++++- .../tagging/test_system_defined_models.py | 12 ++++++------ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/openedx_tagging/core/tagging/models/__init__.py b/openedx_tagging/core/tagging/models/__init__.py index 3dd7ff10..90640ddf 100644 --- a/openedx_tagging/core/tagging/models/__init__.py +++ b/openedx_tagging/core/tagging/models/__init__.py @@ -3,8 +3,9 @@ Taxonomy, ObjectTag, ) - from .system_defined import ( + ModelObjectTag, + ModelSystemDefinedTaxonomy, UserSystemDefinedTaxonomy, - LanguageTaxonomy + LanguageTaxonomy, ) diff --git a/openedx_tagging/core/tagging/models/system_defined.py b/openedx_tagging/core/tagging/models/system_defined.py index 252a8b7d..06db7979 100644 --- a/openedx_tagging/core/tagging/models/system_defined.py +++ b/openedx_tagging/core/tagging/models/system_defined.py @@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model from django.db import models +from openedx_tagging.core.tagging.models.base import ObjectTag + from .base import Tag, Taxonomy, ObjectTag log = logging.getLogger(__name__) @@ -130,7 +132,7 @@ class ModelSystemDefinedTaxonomy(SystemDefinedTaxonomy): """ Model based system taxonomy abstract class. - This type of taxonomy has an associated Django model in `tag_class_model()`. + This type of taxonomy has an associated Django model in ModelObjectTag.tag_class_model(). They are designed to create Tags when required for new ObjectTags, to maintain their status as "closed" taxonomies. The Tags are representations of the instances of the associated model. @@ -162,6 +164,21 @@ def object_tag_class(self) -> Type: Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass. """ raise NotImplementedError + + def _check_instance(self, object_tag: ObjectTag) -> bool: + """ + Returns True if the instance exists + + Subclasses can override this method to perform their own instance validation checks. + """ + object_tag = self.object_tag_class.cast(object_tag) + return bool(object_tag.get_instance()) + + def _check_tag(self, object_tag: ObjectTag) -> bool: + """ + Returns True if the instance is valid + """ + return super()._check_tag(object_tag) and self._check_instance(object_tag) class UserModelObjectTag(ModelObjectTag): diff --git a/tests/openedx_tagging/core/tagging/test_system_defined_models.py b/tests/openedx_tagging/core/tagging/test_system_defined_models.py index a361e01e..6ca1ab43 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined_models.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined_models.py @@ -98,12 +98,12 @@ def test_implementation_error(self, taxonomy_cls, expected_exception): taxonomy_cls() @ddt.data( - (1, "tag_id"), # Valid - (0, "tag_id"), # Invalid user - (1, None), # Testing parent validations + (1, "tag_id", True), # Valid + (0, "tag_id", False), # Invalid user + (1, None, False), # Testing parent validations ) @ddt.unpack - def test_validations(self, tag_external_id, tag_id): + def test_validations(self, tag_external_id, tag_id, expected): tag = Tag( id=tag_id, taxonomy=self.user_taxonomy, @@ -115,12 +115,12 @@ def test_validations(self, tag_external_id, tag_id): tag=tag, ) - self.user_taxonomy.validate_object_tag( + assert self.user_taxonomy.validate_object_tag( object_tag=object_tag, check_object=False, check_taxonomy=False, check_tag=True, - ) + ) == expected def test_tag_object_invalid_user(self): # Test user that doesn't exist