diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index 485f44ac2..b3f475621 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 5d8ca1158..55aa0d376 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -137,3 +137,46 @@ def tag_object( Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. """ return taxonomy.cast().tag_object(tags, object_id) + + +def autocomplete_tags( + taxonomy: Taxonomy, + search: str, + object_id: str = None, + object_tags_only=True, +) -> QuerySet: + """ + Provides auto-complete suggestions by matching the `search` string against existing + ObjectTags linked to the given taxonomy. A case-insensitive search is used in order + to return the highest number of relevant tags. + + If `object_id` is provided, then object tag values already linked to this object + are omitted from the returned suggestions. (ObjectTag values must be unique for a + given object + taxonomy, and so omitting these suggestions helps users avoid + duplication errors.). + + Returns a QuerySet of dictionaries containing distinct `value` (string) and + `tag` (numeric ID) values, sorted alphabetically by `value`. + The `value` is what should be shown as a suggestion to users, + and if it's a free-text taxonomy, `tag` will be `None`: we include the `tag` ID + in anticipation of the second use case listed below. + + Use cases: + * This method is useful for reducing tag variation in free-text taxonomies by showing + users tags that are similar to what they're typing. E.g., if the `search` string "dn" + shows that other objects have been tagged with "DNA", "DNA electrophoresis", and "DNA fingerprinting", + this encourages users to use those existing tags if relevant, instead of creating new ones that + look similar (e.g. "dna finger-printing"). + * It could also be used to assist tagging for closed taxonomies with a list of possible tags which is too + large to return all at once, e.g. a user model taxonomy that dynamically creates tags on request for any + registered user in the database. (Note that this is not implemented yet, but may be as part of a future change.) + """ + if not object_tags_only: + raise NotImplementedError( + _( + "Using this would return a query set of tags instead of object tags." + "For now we recommend fetching all of the taxonomy's tags " + "using get_tags() and filtering them on the frontend." + ) + ) + return taxonomy.cast().autocomplete_tags(search, object_id) diff --git a/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py b/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py new file mode 100644 index 000000000..0ffe5f7c6 --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.19 on 2023-08-02 16:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("oel_tagging", "0005_language_taxonomy"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="objecttag", + unique_together={("taxonomy", "_value", "object_id")}, + ), + ] diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index 499149845..efdbda55a 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -430,6 +430,52 @@ def tag_object( return updated_tags + def autocomplete_tags( + self, + search: str, + object_id: str = None, + ) -> models.QuerySet: + """ + Provides auto-complete suggestions by matching the `search` string against existing + ObjectTags linked to the given taxonomy. A case-insensitive search is used in order + to return the highest number of relevant tags. + + If `object_id` is provided, then object tag values already linked to this object + are omitted from the returned suggestions. (ObjectTag values must be unique for a + given object + taxonomy, and so omitting these suggestions helps users avoid + duplication errors.). + + Returns a QuerySet of dictionaries containing distinct `value` (string) and `tag` + (numeric ID) values, sorted alphabetically by `value`. + + Subclasses can override this method to perform their own autocomplete process. + Subclass use cases: + * Large taxonomy associated with a model. It can be overridden to get + the suggestions directly from the model by doing own filtering. + * Taxonomy with a list of available tags: It can be overridden to only + search the suggestions on a list of available tags. + """ + # Fetch tags that the object already has to exclude them from the result + excluded_tags = [] + if object_id: + excluded_tags = self.objecttag_set.filter(object_id=object_id).values_list( + "_value", flat=True + ) + return ( + # Fetch object tags from this taxonomy whose value contains the search + self.objecttag_set.filter(_value__icontains=search) + # omit any tags whose values match the tags on the given object + .exclude(_value__in=excluded_tags) + # alphabetical ordering + .order_by("_value") + # Alias the `_value` field to `value` to make it nicer for users + .annotate(value=models.F("_value")) + # obtain tag values + .values("value", "tag_id") + # remove repeats + .distinct() + ) + class ObjectTag(models.Model): """ @@ -497,6 +543,7 @@ class Meta: models.Index(fields=["taxonomy", "object_id"]), models.Index(fields=["taxonomy", "_value"]), ] + unique_together = ("taxonomy", "_value", "object_id") def __repr__(self): """ diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index a75aebb61..b6affec4a 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -1,4 +1,5 @@ """ Test the tagging APIs """ +import ddt from django.test.testcases import TestCase, override_settings @@ -16,6 +17,7 @@ ] +@ddt.ddt class TestApiTagging(TestTagTaxonomyMixin, TestCase): """ Test the Tagging API methods. @@ -460,3 +462,127 @@ def test_get_object_tags(self): ) == [ beta, ] + + @ddt.data( + ("ChA", ["Archaea", "Archaebacteria"], [2,5]), + ("ar", ['Archaea', 'Archaebacteria', 'Arthropoda'], [2,5,14]), + ("aE", ['Archaea', 'Archaebacteria', 'Plantae'], [2,5,10]), + ( + "a", + [ + 'Animalia', + 'Archaea', + 'Archaebacteria', + 'Arthropoda', + 'Gastrotrich', + 'Monera', + 'Placozoa', + 'Plantae', + ], + [9,2,5,14,16,13,19,10], + ), + ) + @ddt.unpack + def test_autocomplete_tags(self, search, expected_values, expected_ids): + tags = [ + 'Archaea', + 'Archaebacteria', + 'Animalia', + 'Arthropoda', + 'Plantae', + 'Monera', + 'Gastrotrich', + 'Placozoa', + ] + expected_values # To create repeats + closed_taxonomy = self.taxonomy + open_taxonomy = tagging_api.create_taxonomy( + "Free_Text_Taxonomy", + allow_free_text=True, + ) + + for index, value in enumerate(tags): + # Creating ObjectTags for open taxonomy + ObjectTag( + object_id=f"object_id_{index}", + taxonomy=open_taxonomy, + _value=value, + ).save() + + # Creating ObjectTags for closed taxonomy + tag = get_tag(value) + ObjectTag( + object_id=f"object_id_{index}", + taxonomy=closed_taxonomy, + tag=tag, + _value=value, + ).save() + + # Test for open taxonomy + self._validate_autocomplete_tags( + open_taxonomy, + search, + expected_values, + [None] * len(expected_ids), + ) + + # Test for closed taxonomy + self._validate_autocomplete_tags( + closed_taxonomy, + search, + expected_values, + expected_ids, + ) + + def test_autocompleate_not_implemented(self): + with self.assertRaises(NotImplementedError): + tagging_api.autocomplete_tags(self.taxonomy, 'test', None, object_tags_only=False) + + def _get_tag_values(self, tags): + """ + Get tag values from tagging_api.autocomplete_tags() result + """ + return [tag.get("value") for tag in tags] + + def _get_tag_ids(self, tags): + """ + Get tag ids from tagging_api.autocomplete_tags() result + """ + return [tag.get("tag_id") for tag in tags] + + def _validate_autocomplete_tags( + self, + taxonomy, + search, + expected_values, + expected_ids, + ): + """ + Validate autocomplete tags + """ + + # Normal search + result = tagging_api.autocomplete_tags(taxonomy, search) + tag_values = self._get_tag_values(result) + for value in tag_values: + assert search.lower() in value.lower() + + assert tag_values == expected_values + assert self._get_tag_ids(result) == expected_ids + + # Create ObjectTag to simulate the content tagging + tag_model = None + if not taxonomy.allow_free_text: + tag_model = get_tag(tag_values[0]) + + object_id = 'new_object_id' + ObjectTag( + object_id=object_id, + taxonomy=taxonomy, + tag=tag_model, + _value=tag_values[0], + ).save() + + # Search with object + result = tagging_api.autocomplete_tags(taxonomy, search, object_id) + assert self._get_tag_values(result) == expected_values[1:] + assert self._get_tag_ids(result) == expected_ids[1:] diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index 2db7911af..a30093bd4 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -2,6 +2,7 @@ import ddt from django.contrib.auth import get_user_model from django.test.testcases import TestCase +from django.db.utils import IntegrityError from openedx_tagging.core.tagging.models import ( ObjectTag, @@ -237,6 +238,20 @@ def test_get_tags_shallow_taxonomy(self): with self.assertNumQueries(2): assert taxonomy.get_tags() == tags + def test_unique_tags(self): + # Creating new tag + Tag( + taxonomy=self.taxonomy, + value='New value' + ).save() + + # Creating repeated tag + with self.assertRaises(IntegrityError): + Tag( + taxonomy=self.taxonomy, + value=self.archaea.value, + ).save() + class TestModelObjectTag(TestTagTaxonomyMixin, TestCase): """ diff --git a/tests/openedx_tagging/core/tagging/test_system_defined_models.py b/tests/openedx_tagging/core/tagging/test_system_defined_models.py index 432d07110..783a2604e 100644 --- a/tests/openedx_tagging/core/tagging/test_system_defined_models.py +++ b/tests/openedx_tagging/core/tagging/test_system_defined_models.py @@ -1,6 +1,7 @@ """ Test the tagging system-defined taxonomy models """ import ddt +from django.db.utils import IntegrityError from django.test.testcases import TestCase, override_settings from django.contrib.auth import get_user_model @@ -160,11 +161,8 @@ 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() + with self.assertRaises(IntegrityError): + self._tag_object() def test_tag_object_resync(self): self._tag_object()