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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "0.1.2"
43 changes: 43 additions & 0 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
bradenmacdonald marked this conversation as resolved.
"""
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
Comment thread
bradenmacdonald marked this conversation as resolved.
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)
Original file line number Diff line number Diff line change
@@ -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")},
),
]
47 changes: 47 additions & 0 deletions openedx_tagging/core/tagging/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down
126 changes: 126 additions & 0 deletions tests/openedx_tagging/core/tagging/test_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
""" Test the tagging APIs """
import ddt

from django.test.testcases import TestCase, override_settings

Expand All @@ -16,6 +17,7 @@
]


@ddt.ddt
class TestApiTagging(TestTagTaxonomyMixin, TestCase):
"""
Test the Tagging API methods.
Expand Down Expand Up @@ -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:]
15 changes: 15 additions & 0 deletions tests/openedx_tagging/core/tagging/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()
Expand Down