From 2858e1be07f2e42cd300f899200bfe9352d06c89 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 1 Nov 2023 14:32:01 -0700 Subject: [PATCH] feat: Add an API to retrieve the count of tags applied to each object --- .../core/tagging/rest_api/v1/urls.py | 1 + .../core/tagging/rest_api/v1/views.py | 54 ++++++++++++++++-- .../core/tagging/test_views.py | 56 +++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/openedx_tagging/core/tagging/rest_api/v1/urls.py b/openedx_tagging/core/tagging/rest_api/v1/urls.py index 72ff87d0..b7841b57 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/urls.py +++ b/openedx_tagging/core/tagging/rest_api/v1/urls.py @@ -10,6 +10,7 @@ router = DefaultRouter() router.register("taxonomies", views.TaxonomyView, basename="taxonomy") router.register("object_tags", views.ObjectTagView, basename="object_tag") +router.register("object_tag_counts", views.ObjectTagCountsView, basename="object_tag_counts") urlpatterns = [ path("", include(router.urls)), diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py index 7b6200f8..16798fa1 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -3,6 +3,8 @@ """ from __future__ import annotations +from typing import Any + from django.db import models from django.http import Http404, HttpResponse from rest_framework import mixins, status @@ -26,7 +28,7 @@ from ...data import TagDataQuerySet from ...import_export.api import export_tags from ...import_export.parsers import ParserFormat -from ...models import Taxonomy +from ...models import ObjectTag, Taxonomy from ...rules import ObjectTagPermissionItem from ..paginators import TAGS_THRESHOLD, DisabledTagsPagination, TagsPagination from .permissions import ObjectTagObjectPermissions, TagObjectPermissions, TaxonomyObjectPermissions @@ -266,10 +268,6 @@ class ObjectTagView( * 400 - Invalid query parameter * 403 - Permission denied - **Create Query Returns** - * 403 - Permission denied - * 405 - Method not allowed - **Update Parameters** * object_id (required): - The Object ID to add ObjectTags for. @@ -411,6 +409,52 @@ def update(self, request, *args, **kwargs) -> Response: return self.retrieve(request, object_id) +@view_auth_classes +class ObjectTagCountsView( + mixins.RetrieveModelMixin, + GenericViewSet, +): + """ + View to retrieve the count of ObjectTags for all matching object IDs. + + This API does NOT bother doing any permission checks as the "# of tags" is not considered sensitive information. + + **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. + + **Retrieve Example Requests** + GET api/tagging/v1/object_tag_counts/:object_id_pattern + + **Retrieve Query Returns** + * 200 - Success + """ + + serializer_class = ObjectTagSerializer + lookup_field = "object_id_pattern" + + def retrieve(self, request, *args, **kwargs) -> Response: + """ + Retrieve the counts of object tags that belong to a given object_id pattern + + Note: We override `retrieve` here instead of `list` because we are + passing in the Object ID (object_id) in the path (as opposed to passing + it in as a query_param) to retrieve the ObjectTag counts. + """ + # 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"] + qs: Any = ObjectTag.objects + if object_id_pattern.endswith("*"): + qs = qs.filter(object_id__startswith=object_id_pattern[0:len(object_id_pattern) - 1]) + elif "*" in object_id_pattern: + raise ValidationError("Wildcard matches are only supported if the * is at the end.") + else: + qs = qs.filter(object_id__in=object_id_pattern.split(",")) + + qs = qs.values("object_id").annotate(num_tags=models.Count("id")).order_by("object_id") + return Response({row["object_id"]: row["num_tags"] for row in qs}) + + @view_auth_classes class TaxonomyTagsView(ListAPIView, RetrieveUpdateDestroyAPIView): """ diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index 231a7b5b..cd0b303b 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -32,6 +32,7 @@ OBJECT_TAGS_RETRIEVE_URL = "/tagging/rest_api/v1/object_tags/{object_id}/" +OBJECT_TAG_COUNTS_URL = "/tagging/rest_api/v1/object_tag_counts/{object_id_pattern}/" OBJECT_TAGS_UPDATE_URL = "/tagging/rest_api/v1/object_tags/{object_id}/?taxonomy={taxonomy_id}" LANGUAGE_TAXONOMY_ID = -1 @@ -916,6 +917,61 @@ def test_tag_object_count_limit(self): assert response.status_code == status.HTTP_400_BAD_REQUEST +class TestObjectTagCountsViewSet(TestTagTaxonomyMixin, APITestCase): + """ + Testing various cases for counting how many tags are applied to several objects. + """ + + def test_get_counts(self): + """ + Test retrieving the counts of tags applied to various content objects. + + This API does NOT bother doing any permission checks as the "# of tags" is not considered sensitive information. + """ + # Course 2 + api.tag_object(object_id="course02-unit01-problem01", taxonomy=self.free_text_taxonomy, tags=["other"]) + # Course 7 Unit 1 + api.tag_object(object_id="course07-unit01-problem01", taxonomy=self.free_text_taxonomy, tags=["a", "b", "c"]) + api.tag_object(object_id="course07-unit01-problem02", taxonomy=self.free_text_taxonomy, tags=["a", "b"]) + # 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)) + assert result.status_code == status.HTTP_200_OK + return result.data + + with self.assertNumQueries(1): + assert check(object_id_pattern="course02-*") == { + "course02-unit01-problem01": 1, + } + with self.assertNumQueries(1): + assert check(object_id_pattern="course07-unit01-*") == { + "course07-unit01-problem01": 3, + "course07-unit01-problem02": 2, + } + with self.assertNumQueries(1): + assert check(object_id_pattern="course07-unit*") == { + "course07-unit01-problem01": 3, + "course07-unit01-problem02": 2, + "course07-unit02-problem01": 1, + "course07-unit02-problem02": 2, + "course07-unit02-problem03": 3, + } + # Can also use a comma to separate explicit object IDs: + with self.assertNumQueries(1): + assert check(object_id_pattern="course07-unit01-problem01") == { + "course07-unit01-problem01": 3, + } + with self.assertNumQueries(1): + assert check(object_id_pattern="course07-unit01-problem01,course07-unit02-problem02") == { + "course07-unit01-problem01": 3, + "course07-unit02-problem02": 2, + } + + class TestTaxonomyTagsView(TestTaxonomyViewMixin): """ Tests the list/create/update/delete tags of taxonomy view