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/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..0036432b 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,16 @@ 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 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 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 @@ -32,27 +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 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'), 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/api.py b/openedx_tagging/core/tagging/api.py index 6b9bf2a0..5d8ca115 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,36 @@ 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)] + ObjectTagClass = taxonomy.object_tag_class if taxonomy else ObjectTag + tags = ( + ObjectTagClass.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 +136,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/fixtures/language_taxonomy.yaml b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml new file mode 100644 index 00000000..ac2c934e --- /dev/null +++ b/openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml @@ -0,0 +1,1288 @@ +- 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/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..2acd178f --- /dev/null +++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py @@ -0,0 +1,49 @@ +""" +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 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): + 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: + 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") + # System tags are identified with negative numbers to avoid clashing with user-created tags. + 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] 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/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/models/__init__.py b/openedx_tagging/core/tagging/models/__init__.py new file mode 100644 index 00000000..90640ddf --- /dev/null +++ b/openedx_tagging/core/tagging/models/__init__.py @@ -0,0 +1,11 @@ +from .base import ( + Tag, + Taxonomy, + ObjectTag, +) +from .system_defined import ( + ModelObjectTag, + ModelSystemDefinedTaxonomy, + UserSystemDefinedTaxonomy, + LanguageTaxonomy, +) diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models/base.py similarity index 57% rename from openedx_tagging/core/tagging/models.py rename to openedx_tagging/core/tagging/models/base.py index c90f8a34..8af03796 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -1,11 +1,15 @@ -""" Tagging app data models """ -from typing import List, Type +""" Tagging app base data models """ +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,22 +140,127 @@ class Taxonomy(models.Model): "Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values." ), ) + 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. + """ + 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 - def system_defined(self) -> bool: + 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. """ - Base taxonomies are user-defined, not system-defined. + return ObjectTag - System-defined taxonomies cannot be edited by ordinary users. + @property + def taxonomy_class(self) -> Type: + """ + Returns the Taxonomy subclass associated with this instance, or None if none supplied. - Subclasses should override this property as required. + May raise ImportError if a custom taxonomy_class cannot be imported. + """ + 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 - def get_tags(self) -> List[Tag]: + @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): + """ + 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}" + ) + + return self + + def copy(self, taxonomy: "Taxonomy") -> "Taxonomy": + """ + 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.visible_to_authors = taxonomy.visible_to_authors + self._taxonomy_class = taxonomy._taxonomy_class + return self + + 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. @@ -163,9 +272,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.tag_set.prefetch_related("parent") + filtered_tags = tag_set.prefetch_related("parent") if parents is None: filtered_tags = filtered_tags.filter(parent=None) else: @@ -195,38 +307,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. """ @@ -239,10 +385,12 @@ 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( - taxonomy=self, object_id=object_id, object_type=object_type + for tag in ObjectTagClass.objects.filter( + taxonomy=self, + object_id=object_id, ) } updated_tags = [] @@ -250,21 +398,12 @@ 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_type=object_type, - ) - - 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.tag_ref = tag_ref object_tag.resync() if not self.validate_object_tag(object_tag): raise ValueError( @@ -304,12 +443,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 +487,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 +543,25 @@ def tag_ref(self) -> str: """ return self.tag.id if self.tag_id else self._value - @property + @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. @@ -426,25 +592,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 +639,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/models/system_defined.py b/openedx_tagging/core/tagging/models/system_defined.py new file mode 100644 index 00000000..06db7979 --- /dev/null +++ b/openedx_tagging/core/tagging/models/system_defined.py @@ -0,0 +1,267 @@ +""" 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 openedx_tagging.core.tagging.models.base import ObjectTag + +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 + + @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 True + + +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 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. + + 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 + + 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): + """ + 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/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..4633901a 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: @@ -154,3 +161,33 @@ required: false allow_multiple: false allow_free_text: 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 + _taxonomy_class: openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy +- 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: false + _taxonomy_class: openedx_tagging.core.tagging.models.system_defined.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 + _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 7a418e68..654da846 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,55 @@ 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()) - disabled = tagging_api.get_taxonomies(enabled=False) - assert list(disabled) == [tax2] + assert enabled == [ + tax1, + self.taxonomy, + self.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]) == " (4) System defined taxonomy" - both = tagging_api.get_taxonomies(enabled=None) - assert list(both) == [tax2, tax1, self.taxonomy] + with self.assertNumQueries(1): + disabled = list(tagging_api.get_taxonomies(enabled=False)) + assert disabled == [tax2] + assert str(disabled[0]) == f" ({tax2.id}) Disabled" + + with self.assertNumQueries(1): + both = list(tagging_api.get_taxonomies(enabled=None)) + assert both == [ + tax2, + tax1, + self.taxonomy, + self.system_taxonomy, + self.language_taxonomy, + self.user_taxonomy, + ] def test_get_tags(self): self.setup_tag_depths() @@ -59,23 +94,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 +127,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 +174,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 +197,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 +233,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 +249,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..8d84bf0b 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -1,9 +1,13 @@ -""" 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 -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from openedx_tagging.core.tagging.models import ( + ObjectTag, + Tag, + Taxonomy, +) def get_tag(value): @@ -23,12 +27,28 @@ 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.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) @@ -77,6 +97,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 +138,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.language_taxonomy) + == repr(self.language_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 +236,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 +296,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, ) @@ -199,42 +312,42 @@ 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): - 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 +373,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 +388,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 +403,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 +418,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 +426,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, 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..6ca1ab43 --- /dev/null +++ b/tests/openedx_tagging/core/tagging/test_system_defined_models.py @@ -0,0 +1,237 @@ +""" 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, + 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 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", 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, expected): + tag = Tag( + id=tag_id, + taxonomy=self.user_taxonomy, + value="_val", + external_id=tag_external_id, + ) + object_tag = ObjectTag( + object_id="id", + tag=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 + 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", + ) + 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