Skip to content

Commit

Permalink
feat: Count implicit tags
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed Dec 14, 2023
1 parent 3e2d6a4 commit 43547b1
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 8 deletions.
15 changes: 12 additions & 3 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ def get_object_tags(
return tags


def get_object_tag_counts(object_id_pattern: str) -> dict[str, int]:
def get_object_tag_counts(object_id_pattern: str, count_implicit=False) -> dict[str, int]:
"""
Given an object ID, a "starts with" glob pattern like
"course-v1:foo+bar+baz@*", or a list of "comma,separated,IDs", return a
Expand All @@ -217,8 +217,17 @@ def get_object_tag_counts(object_id_pattern: str) -> dict[str, int]:
qs = qs.exclude(taxonomy_id=None) # The whole taxonomy was deleted
qs = qs.exclude(taxonomy__enabled=False) # The whole taxonomy is disabled
qs = qs.exclude(tag_id=None, taxonomy__allow_free_text=False) # The taxonomy exists but the tag is deleted
qs = qs.values("object_id").annotate(num_tags=models.Count("id")).order_by("object_id")
return {row["object_id"]: row["num_tags"] for row in qs}
if count_implicit:
tags = Tag.annotate_depth(Tag.objects.filter(pk=models.OuterRef("tag_id")))
qs = qs.annotate(tag_depth=models.Subquery(tags.values('depth')))
qs = qs.values("object_id").annotate(
num_tags=models.Count("id"),
num_implicit_tags=models.Sum("tag_depth"),
).order_by("object_id")
return {row["object_id"]: row["num_tags"] + (row["num_implicit_tags"] or 0) for row in qs}
else:
qs = qs.values("object_id").annotate(num_tags=models.Count("id")).order_by("object_id")
return {row["object_id"]: row["num_tags"] for row in qs}


def delete_object_tags(object_id: str):
Expand Down
5 changes: 4 additions & 1 deletion openedx_tagging/core/tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,9 +496,11 @@ class ObjectTagCountsView(
**Retrieve Parameters**
* object_id_pattern (required): - The Object ID to retrieve ObjectTags for. Can contain '*' at the end
for wildcard matching, or use ',' to separate multiple object IDs.
* count_implicit (optional): If present, implicit parent/grandparent tags will be included in the counts
**Retrieve Example Requests**
GET api/tagging/v1/object_tag_counts/:object_id_pattern
GET api/tagging/v1/object_tag_counts/:object_id_pattern?count_implicit
**Retrieve Query Returns**
* 200 - Success
Expand All @@ -517,8 +519,9 @@ def retrieve(self, request, *args, **kwargs) -> Response:
"""
# This API does NOT bother doing any permission checks as the # of tags is not considered sensitive information.
object_id_pattern = self.kwargs["object_id_pattern"]
count_implicit = "count_implicit" in request.query_params
try:
return Response(get_object_tag_counts(object_id_pattern))
return Response(get_object_tag_counts(object_id_pattern, count_implicit=count_implicit))
except ValueError as err:
raise ValidationError(err.args[0]) from err

Expand Down
26 changes: 26 additions & 0 deletions tests/openedx_tagging/core/tagging/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,29 @@ def test_get_object_tag_counts(self) -> None:
assert tagging_api.get_object_tag_counts(f"{obj1},{obj2}") == {obj1: 1, obj2: 2}
assert tagging_api.get_object_tag_counts("object_*") == {obj1: 1, obj2: 2}

def test_get_object_tag_counts_implicit(self) -> None:
"""
Basic test of get_object_tag_counts, including implicit (parent) tags
Note that:
- "DPANN" is "Archaea > DPANN" (2 tags, 1 implicit), and
- "Chordata" is "Eukaryota > Animalia > Chordata" (3 tags, 2 implicit)
"""
obj1 = "object_id1"
obj2 = "object_id2"
other = "other_object"
# Give each object 1-2 tags:
tagging_api.tag_object(object_id=obj1, taxonomy=self.taxonomy, tags=["DPANN"])
tagging_api.tag_object(object_id=obj2, taxonomy=self.taxonomy, tags=["Chordata"])
tagging_api.tag_object(object_id=obj2, taxonomy=self.free_text_taxonomy, tags=["has a notochord"])
tagging_api.tag_object(object_id=other, taxonomy=self.free_text_taxonomy, tags=["other"])

assert tagging_api.get_object_tag_counts(obj1, count_implicit=True) == {obj1: 2}
assert tagging_api.get_object_tag_counts(obj2, count_implicit=True) == {obj2: 4}
assert tagging_api.get_object_tag_counts(f"{obj1},{obj2}", count_implicit=True) == {obj1: 2, obj2: 4}
assert tagging_api.get_object_tag_counts("object_*", count_implicit=True) == {obj1: 2, obj2: 4}
assert tagging_api.get_object_tag_counts(other, count_implicit=True) == {other: 1}

def test_get_object_tag_counts_deleted_disabled(self) -> None:
"""
Test that get_object_tag_counts doesn't "count" disabled taxonomies or
Expand All @@ -726,6 +749,9 @@ def test_get_object_tag_counts_deleted_disabled(self) -> None:
self.free_text_taxonomy.enabled = False
self.free_text_taxonomy.save()
assert tagging_api.get_object_tag_counts("object_*") == {obj1: 1, obj2: 1}
# Also check the result with count_implicit:
# "English" has no implicit tags but "Chordata" has two, so we expect these totals:
assert tagging_api.get_object_tag_counts("object_*", count_implicit=True) == {obj1: 1, obj2: 3}

# But, by the way, if we re-enable the taxonomy and restore the tag, the counts return:
self.free_text_taxonomy.enabled = True
Expand Down
26 changes: 22 additions & 4 deletions tests/openedx_tagging/core/tagging/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,10 +1028,14 @@ def test_get_counts(self):
# Course 7 Unit 2
api.tag_object(object_id="course07-unit02-problem01", taxonomy=self.free_text_taxonomy, tags=["b"])
api.tag_object(object_id="course07-unit02-problem02", taxonomy=self.free_text_taxonomy, tags=["c", "d"])
api.tag_object(object_id="course07-unit02-problem03", taxonomy=self.free_text_taxonomy, tags=["N", "M", "x"])

def check(object_id_pattern: str):
result = self.client.get(OBJECT_TAG_COUNTS_URL.format(object_id_pattern=object_id_pattern))
api.tag_object(object_id="course07-unit02-problem03", taxonomy=self.free_text_taxonomy, tags=["N", "M"])
api.tag_object(object_id="course07-unit02-problem03", taxonomy=self.taxonomy, tags=["Mammalia"])

def check(object_id_pattern: str, count_implicit=False):
url = OBJECT_TAG_COUNTS_URL.format(object_id_pattern=object_id_pattern)
if count_implicit:
url += "?count_implicit"
result = self.client.get(url)
assert result.status_code == status.HTTP_200_OK
return result.data

Expand All @@ -1044,6 +1048,20 @@ def check(object_id_pattern: str):
"course07-unit01-problem01": 3,
"course07-unit01-problem02": 2,
}
with self.assertNumQueries(1):
assert check(object_id_pattern="course07-unit02-*") == {
"course07-unit02-problem01": 1,
"course07-unit02-problem02": 2,
"course07-unit02-problem03": 3,
}
with self.assertNumQueries(1):
assert check(object_id_pattern="course07-unit02-*", count_implicit=True) == {
"course07-unit02-problem01": 1,
"course07-unit02-problem02": 2,
# "Mammalia" includes 1 explicit + 3 implicit tags: "Eukaryota > Animalia > Chordata > Mammalia"
# so problem03 has 2 free text tags and "4" life on earth tags:
"course07-unit02-problem03": 6,
}
with self.assertNumQueries(1):
assert check(object_id_pattern="course07-unit*") == {
"course07-unit01-problem01": 3,
Expand Down

0 comments on commit 43547b1

Please sign in to comment.