diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index bbab0242..0a8da882 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.6" diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index ab1fef91..0ccb4688 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -96,16 +96,14 @@ def resync_object_tags(object_tags: QuerySet = None) -> int: def get_object_tags( - object_id: str, taxonomy: Taxonomy = None, valid_only=True -) -> Iterator[ObjectTag]: + object_id: str, taxonomy_id: str = None +) -> QuerySet: """ - Generates a list of object tags for a given object. + Returns a Queryset 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 (probably) be hidden from learners. """ + taxonomy = get_taxonomy(taxonomy_id) ObjectTagClass = taxonomy.object_tag_class if taxonomy else ObjectTag tags = ( ObjectTagClass.objects.filter( @@ -117,9 +115,7 @@ def get_object_tags( if taxonomy: tags = tags.filter(taxonomy=taxonomy) - for object_tag in tags: - if not valid_only or object_tag.is_valid(): - yield object_tag + return tags def delete_object_tags(object_id: str): diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index efdbda55..35149c9d 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -381,11 +381,31 @@ def tag_object( """ 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. + Otherwise, it should be either a list of existing Tag Values or 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. """ + def _find_object_tag_index(tag_ref, object_tags) -> int: + """ + Search for Tag in the given list of ObjectTags by tag_ref or value, + returning its index or -1 if not found. + """ + return next( + ( + i + for i, object_tag in enumerate(object_tags) + if object_tag.tag_ref == tag_ref or object_tag.value == tag_ref + ), + -1, + ) + + + if not isinstance(tags, list): + raise ValueError(_(f"Tags must be a list, not {type(tags).__name__}.")) + + tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order + if not self.allow_multiple and len(tags) > 1: raise ValueError(_(f"Taxonomy ({self.id}) only allows one tag per object.")) @@ -395,17 +415,17 @@ def tag_object( ) ObjectTagClass = self.object_tag_class - current_tags = { - tag.tag_ref: tag - for tag in ObjectTagClass.objects.filter( + current_tags = list( + ObjectTagClass.objects.filter( taxonomy=self, object_id=object_id, ) - } + ) updated_tags = [] for tag_ref in tags: - if tag_ref in current_tags: - object_tag = current_tags.pop(tag_ref) + object_tag_index = _find_object_tag_index(tag_ref, current_tags) + if object_tag_index >= 0: + object_tag = current_tags.pop(object_tag_index) else: object_tag = ObjectTagClass( taxonomy=self, @@ -425,7 +445,7 @@ def tag_object( object_tag.save() # ...and delete any omitted existing tags - for old_tag in current_tags.values(): + for old_tag in current_tags: old_tag.delete() return updated_tags diff --git a/openedx_tagging/core/tagging/rest_api/v1/permissions.py b/openedx_tagging/core/tagging/rest_api/v1/permissions.py index e8915a62..245fc3cb 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/permissions.py +++ b/openedx_tagging/core/tagging/rest_api/v1/permissions.py @@ -1,5 +1,5 @@ """ -Taxonomy permissions +Tagging permissions """ from rest_framework.permissions import DjangoObjectPermissions @@ -15,3 +15,15 @@ class TaxonomyObjectPermissions(DjangoObjectPermissions): "PATCH": ["%(app_label)s.change_%(model_name)s"], "DELETE": ["%(app_label)s.delete_%(model_name)s"], } + + +class ObjectTagObjectPermissions(DjangoObjectPermissions): + perms_map = { + "GET": ["%(app_label)s.view_%(model_name)s"], + "OPTIONS": [], + "HEAD": ["%(app_label)s.view_%(model_name)s"], + "POST": ["%(app_label)s.add_%(model_name)s"], + "PUT": ["%(app_label)s.change_%(model_name)s"], + "PATCH": ["%(app_label)s.change_%(model_name)s"], + "DELETE": ["%(app_label)s.delete_%(model_name)s"], + } diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py index 53f64742..354c56e4 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers -from openedx_tagging.core.tagging.models import Taxonomy +from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy class TaxonomyListQueryParamsSerializer(serializers.Serializer): @@ -29,3 +29,43 @@ class Meta: "system_defined", "visible_to_authors", ] + + +class ObjectTagListQueryParamsSerializer(serializers.Serializer): + """ + Serializer for the query params for the ObjectTag GET view + """ + + taxonomy = serializers.PrimaryKeyRelatedField(queryset=Taxonomy.objects.all(), required=False) + + +class ObjectTagSerializer(serializers.ModelSerializer): + """ + Serializer for the ObjectTag model. + """ + + class Meta: + model = ObjectTag + fields = [ + "name", + "value", + "taxonomy_id", + "tag_ref", + "is_valid", + ] + + +class ObjectTagUpdateBodySerializer(serializers.Serializer): + """ + Serializer of the body for the ObjectTag UPDATE view + """ + + tags = serializers.ListField(child=serializers.CharField(), required=True) + + +class ObjectTagUpdateQueryParamsSerializer(serializers.Serializer): + """ + Serializer of the query params for the ObjectTag UPDATE view + """ + + taxonomy = serializers.PrimaryKeyRelatedField(queryset=Taxonomy.objects.all(), required=True) diff --git a/openedx_tagging/core/tagging/rest_api/v1/urls.py b/openedx_tagging/core/tagging/rest_api/v1/urls.py index 80500990..c449eb5c 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/urls.py +++ b/openedx_tagging/core/tagging/rest_api/v1/urls.py @@ -10,5 +10,6 @@ router = DefaultRouter() router.register("taxonomies", views.TaxonomyView, basename="taxonomy") +router.register("object_tags", views.ObjectTagView, basename="object_tag") 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 a86e3c27..e695316a 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -2,15 +2,21 @@ Tagging API Views """ from django.http import Http404 -from rest_framework.viewsets import ModelViewSet +from drf_yasg.utils import swagger_auto_schema +from rest_framework import mixins +from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError +from rest_framework.viewsets import GenericViewSet, ModelViewSet -from ...api import ( - create_taxonomy, - get_taxonomy, - get_taxonomies, +from ...api import create_taxonomy, get_object_tags, get_taxonomies, get_taxonomy, tag_object +from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions +from .serializers import ( + ObjectTagListQueryParamsSerializer, + ObjectTagSerializer, + ObjectTagUpdateBodySerializer, + ObjectTagUpdateQueryParamsSerializer, + TaxonomyListQueryParamsSerializer, + TaxonomySerializer, ) -from .permissions import TaxonomyObjectPermissions -from .serializers import TaxonomyListQueryParamsSerializer, TaxonomySerializer class TaxonomyView(ModelViewSet): @@ -145,3 +151,142 @@ def perform_create(self, serializer): Create a new taxonomy. """ serializer.instance = create_taxonomy(**serializer.validated_data) + + +class ObjectTagView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, GenericViewSet): + """ + View to retrieve paginated ObjectTags for a provided Object ID (object_id). + + **Retrieve Parameters** + * object_id (required): - The Object ID to retrieve ObjectTags for. + + **Retrieve Query Parameters** + * taxonomy (optional) - PK of taxonomy to filter ObjectTags for. + * page (optional) - Page number of paginated results. + * page_size (optional) - Number of results included in each page. + + **Retrieve Example Requests** + GET api/tagging/v1/object_tags/:object_id + GET api/tagging/v1/object_tags/:object_id?taxonomy=1 + GET api/tagging/v1/object_tags/:object_id?taxonomy=1&page=2 + GET api/tagging/v1/object_tags/:object_id?taxonomy=1&page=2&page_size=10 + + **Retrieve Query Returns** + * 200 - Success + * 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 retrieve ObjectTags for. + + **Update Request Body** + * tags: List of tags to be applied to a object id. Must be a list of Tag ids or Tag values. + + + **Update Query Returns** + ToDo: update docstring + * 403 - Permission denied + * 405 - Method not allowed + + **Delete Query Returns** + * 403 - Permission denied + * 405 - Method not allowed + """ + + serializer_class = ObjectTagSerializer + permission_classes = [ObjectTagObjectPermissions] + lookup_field = "object_id" + + def get_queryset(self): + """ + Return a queryset of object tags for a given object. + + If a taxonomy is passed in, object tags are limited to that taxonomy. + """ + object_id = self.kwargs.get("object_id") + query_params = ObjectTagListQueryParamsSerializer( + data=self.request.query_params.dict() + ) + query_params.is_valid(raise_exception=True) + taxonomy_id = query_params.data.get("taxonomy", None) + return get_object_tags(object_id, taxonomy_id) + + def retrieve(self, request, object_id=None): + """ + Retrieve ObjectTags that belong to a given object_id and + return paginated results. + + 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 related ObjectTags. + By default retrieve would expect an ObjectTag ID to be passed in the + path and returns a it as a single result however that is not + behavior we want. + """ + object_tags = self.get_queryset() + paginated_object_tags = self.paginate_queryset(object_tags) + serializer = ObjectTagSerializer(paginated_object_tags, many=True) + return self.get_paginated_response(serializer.data) + + @swagger_auto_schema( + query_serializer=ObjectTagUpdateQueryParamsSerializer, + request_body=ObjectTagUpdateBodySerializer, + responses={ + 200: ObjectTagSerializer, + 400: "Invalid request body", + 403: "Permission denied", + }, + ) + def update(self, request, object_id=None, partial=False): + """ + Update ObjectTags that belong to a given object_id and + return the list of these ObjecTags paginated. + + Pass a list of Tag ids or Tag values to be applied to a object id in the + body `tag` parameter. Passing an empty list will remove all tags from + the object id. + + **Example Body Requests** + + PUT api/tagging/v1/object_tags/:object_id + + **Example Body Requests** + ```json + { + "tags": [1, 2, 3] + }, + { + "tags": ["Tag 1", "Tag 2"] + }, + { + "tags": [] + } + """ + + if partial: + raise MethodNotAllowed("PATCH", detail="PATCH not allowed") + + query_params = ObjectTagUpdateQueryParamsSerializer(data=request.query_params.dict()) + query_params.is_valid(raise_exception=True) + taxonomy = query_params.validated_data.get("taxonomy", None) + taxonomy = taxonomy.cast() + + perm = f"{taxonomy._meta.app_label}.change_objecttag" + + if not request.user.has_perm(perm, taxonomy): + raise PermissionDenied("You do not have permission to change object tags for this taxonomy.") + + body = ObjectTagUpdateBodySerializer(data=request.data) + body.is_valid(raise_exception=True) + + tags = body.data.get("tags", []) + try: + tag_object(taxonomy, tags, object_id) + except ValueError as e: + raise ValidationError(e) + + return self.retrieve(request, object_id) diff --git a/openedx_tagging/core/tagging/rules.py b/openedx_tagging/core/tagging/rules.py index 364178c7..b807ed95 100644 --- a/openedx_tagging/core/tagging/rules.py +++ b/openedx_tagging/core/tagging/rules.py @@ -47,18 +47,20 @@ def can_change_tag(user: User, tag: Tag = None) -> bool: @rules.predicate -def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool: +def can_change_object_tag(user: User, taxonomy: Taxonomy = None) -> bool: """ - Taxonomy admins can create or modify object tags on enabled taxonomies. + Everyone can potentially create/edit object tags (taxonomy=None). The object permission must be checked + to determine if the user can create/edit a object_tag for a specific taxonomy. + + Everyone can create or modify object tags on enabled taxonomies. + Only taxonomy admins can create or modify object tags on disabled taxonomies. """ - taxonomy = ( - object_tag.taxonomy.cast() if (object_tag and object_tag.taxonomy_id) else None - ) - object_tag = taxonomy.object_tag_class.cast(object_tag) if taxonomy else object_tag - return is_taxonomy_admin(user) and ( - not object_tag or not taxonomy or (taxonomy and taxonomy.enabled) - ) + if not taxonomy: + return True + + taxonomy = taxonomy.cast() + return taxonomy.enabled or is_taxonomy_admin(user) # Taxonomy rules.add_perm("oel_tagging.add_taxonomy", can_change_taxonomy) @@ -73,7 +75,7 @@ def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool: rules.add_perm("oel_tagging.view_tag", rules.always_allow) # ObjectTag -rules.add_perm("oel_tagging.add_object_tag", can_change_object_tag) -rules.add_perm("oel_tagging.change_object_tag", can_change_object_tag) -rules.add_perm("oel_tagging.delete_object_tag", is_taxonomy_admin) -rules.add_perm("oel_tagging.view_object_tag", rules.always_allow) +rules.add_perm("oel_tagging.add_objecttag", can_change_object_tag) +rules.add_perm("oel_tagging.change_objecttag", can_change_object_tag) +rules.add_perm("oel_tagging.delete_objecttag", is_taxonomy_admin) +rules.add_perm("oel_tagging.view_objecttag", rules.always_allow) diff --git a/requirements/base.in b/requirements/base.in index 33b66281..c6641e59 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,5 +9,6 @@ Django<5.0 # Web application framework djangorestframework<4.0 # REST API edx-drf-extensions # Extensions to the Django REST Framework used by Open edX +drf_yasg # Generate OpenAPI 2.0 (Swagger) and 3.0.2 (OpenAPI) API documentation rules<4.0 # Django extension for rules-based authorization checks diff --git a/requirements/base.txt b/requirements/base.txt index 3ad003af..ff989c10 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -10,6 +10,10 @@ asgiref==3.7.2 # via django attrs==23.1.0 # via -r requirements/base.in +backports-zoneinfo[tzdata]==0.2.1 + # via + # celery + # kombu billiard==4.1.0 # via celery celery==5.3.1 @@ -45,6 +49,7 @@ django==3.2.19 # django-waffle # djangorestframework # drf-jwt + # drf-yasg # edx-django-utils # edx-drf-extensions django-crum==0.7.9 @@ -57,9 +62,12 @@ djangorestframework==3.14.0 # via # -r requirements/base.in # drf-jwt + # drf-yasg # edx-drf-extensions drf-jwt==1.19.2 # via edx-drf-extensions +drf-yasg==1.21.7 + # via -r requirements/base.in edx-django-utils==5.7.0 # via edx-drf-extensions edx-drf-extensions==8.8.0 @@ -68,10 +76,14 @@ edx-opaque-keys==2.4.0 # via edx-drf-extensions idna==3.4 # via requests +inflection==0.5.1 + # via drf-yasg kombu==5.3.1 # via celery newrelic==8.9.0 # via edx-django-utils +packaging==23.1 + # via drf-yasg pbr==5.11.1 # via stevedore prompt-toolkit==3.0.39 @@ -96,6 +108,9 @@ pytz==2023.3 # via # django # djangorestframework + # drf-yasg +pyyaml==6.0.1 + # via drf-yasg requests==2.31.0 # via edx-drf-extensions rules==3.3 @@ -113,9 +128,15 @@ stevedore==5.1.0 # edx-django-utils # edx-opaque-keys typing-extensions==4.6.3 - # via asgiref + # via + # asgiref + # kombu tzdata==2023.3 - # via celery + # via + # backports-zoneinfo + # celery +uritemplate==4.1.1 + # via drf-yasg urllib3==2.0.4 # via requests vine==5.0.0 diff --git a/requirements/ci.txt b/requirements/ci.txt index f47e116e..c03598b3 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade diff --git a/requirements/dev.txt b/requirements/dev.txt index d5ad00f9..b19d531f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -19,6 +19,11 @@ astroid==2.15.5 # pylint-celery attrs==23.1.0 # via -r requirements/quality.txt +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/quality.txt + # celery + # kombu billiard==4.1.0 # via # -r requirements/quality.txt @@ -113,6 +118,7 @@ django==3.2.19 # django-waffle # djangorestframework # drf-jwt + # drf-yasg # edx-django-utils # edx-drf-extensions # edx-i18n-tools @@ -133,6 +139,7 @@ djangorestframework==3.14.0 # via # -r requirements/quality.txt # drf-jwt + # drf-yasg # edx-drf-extensions docutils==0.20.1 # via @@ -142,6 +149,8 @@ drf-jwt==1.19.2 # via # -r requirements/quality.txt # edx-drf-extensions +drf-yasg==1.21.7 + # via -r requirements/quality.txt edx-django-utils==5.7.0 # via # -r requirements/quality.txt @@ -183,6 +192,14 @@ importlib-metadata==6.7.0 # -r requirements/quality.txt # keyring # twine +importlib-resources==6.0.1 + # via + # -r requirements/quality.txt + # keyring +inflection==0.5.1 + # via + # -r requirements/quality.txt + # drf-yasg iniconfig==2.0.0 # via # -r requirements/quality.txt @@ -251,6 +268,7 @@ packaging==23.1 # -r requirements/pip-tools.txt # -r requirements/quality.txt # build + # drf-yasg # pytest # tox path==16.6.0 @@ -366,10 +384,12 @@ pytz==2023.3 # -r requirements/quality.txt # django # djangorestframework -pyyaml==6.0 + # drf-yasg +pyyaml==6.0.1 # via # -r requirements/quality.txt # code-annotations + # drf-yasg # edx-i18n-tools readme-renderer==40.0 # via @@ -464,10 +484,18 @@ typing-extensions==4.6.3 # astroid # grimp # import-linter + # kombu + # pylint + # rich tzdata==2023.3 # via # -r requirements/quality.txt + # backports-zoneinfo # celery +uritemplate==4.1.1 + # via + # -r requirements/quality.txt + # drf-yasg urllib3==2.0.4 # via # -r requirements/quality.txt @@ -503,6 +531,7 @@ zipp==3.15.0 # via # -r requirements/quality.txt # importlib-metadata + # importlib-resources # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index 06ae86ff..dd5eaa58 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -22,6 +22,11 @@ babel==2.12.1 # via # pydata-sphinx-theme # sphinx +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/test.txt + # celery + # kombu beautifulsoup4==4.12.2 # via pydata-sphinx-theme billiard==4.1.0 @@ -88,6 +93,7 @@ django==3.2.19 # django-waffle # djangorestframework # drf-jwt + # drf-yasg # edx-django-utils # edx-drf-extensions # sphinxcontrib-django @@ -106,6 +112,7 @@ djangorestframework==3.14.0 # via # -r requirements/test.txt # drf-jwt + # drf-yasg # edx-drf-extensions doc8==1.1.1 # via -r requirements/doc.in @@ -120,6 +127,8 @@ drf-jwt==1.19.2 # via # -r requirements/test.txt # edx-drf-extensions +drf-yasg==1.21.7 + # via -r requirements/test.txt edx-django-utils==5.7.0 # via # -r requirements/test.txt @@ -146,6 +155,12 @@ imagesize==1.4.1 # via sphinx import-linter==1.9.0 # via -r requirements/test.txt +importlib-metadata==6.8.0 + # via sphinx +inflection==0.5.1 + # via + # -r requirements/test.txt + # drf-yasg iniconfig==2.0.0 # via # -r requirements/test.txt @@ -174,6 +189,7 @@ newrelic==8.9.0 packaging==23.1 # via # -r requirements/test.txt + # drf-yasg # pydata-sphinx-theme # pytest # sphinx @@ -242,12 +258,15 @@ python-slugify==8.0.1 pytz==2023.3 # via # -r requirements/test.txt + # babel # django # djangorestframework -pyyaml==6.0 + # drf-yasg +pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations + # drf-yasg readme-renderer==40.0 # via -r requirements/doc.in requests==2.31.0 @@ -324,11 +343,17 @@ typing-extensions==4.6.3 # asgiref # grimp # import-linter + # kombu # pydata-sphinx-theme tzdata==2023.3 # via # -r requirements/test.txt + # backports-zoneinfo # celery +uritemplate==4.1.1 + # via + # -r requirements/test.txt + # drf-yasg urllib3==2.0.4 # via # -r requirements/test.txt @@ -345,3 +370,5 @@ wcwidth==0.2.6 # prompt-toolkit webencodings==0.5.1 # via bleach +zipp==3.16.2 + # via importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 9ce8ce22..4b7fb4e5 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade diff --git a/requirements/quality.txt b/requirements/quality.txt index 22fb452e..24cd414f 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -18,6 +18,11 @@ astroid==2.15.5 # pylint-celery attrs==23.1.0 # via -r requirements/test.txt +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/test.txt + # celery + # kombu billiard==4.1.0 # via # -r requirements/test.txt @@ -91,6 +96,7 @@ django==3.2.19 # django-waffle # djangorestframework # drf-jwt + # drf-yasg # edx-django-utils # edx-drf-extensions django-crum==0.7.9 @@ -108,6 +114,7 @@ djangorestframework==3.14.0 # via # -r requirements/test.txt # drf-jwt + # drf-yasg # edx-drf-extensions docutils==0.20.1 # via readme-renderer @@ -115,6 +122,8 @@ drf-jwt==1.19.2 # via # -r requirements/test.txt # edx-drf-extensions +drf-yasg==1.21.7 + # via -r requirements/test.txt edx-django-utils==5.7.0 # via # -r requirements/test.txt @@ -145,6 +154,12 @@ importlib-metadata==6.7.0 # via # keyring # twine +importlib-resources==6.0.1 + # via keyring +inflection==0.5.1 + # via + # -r requirements/test.txt + # drf-yasg iniconfig==2.0.0 # via # -r requirements/test.txt @@ -194,6 +209,7 @@ newrelic==8.9.0 packaging==23.1 # via # -r requirements/test.txt + # drf-yasg # pytest pbr==5.11.1 # via @@ -277,10 +293,12 @@ pytz==2023.3 # -r requirements/test.txt # django # djangorestframework -pyyaml==6.0 + # drf-yasg +pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations + # drf-yasg readme-renderer==40.0 # via twine requests==2.31.0 @@ -345,10 +363,18 @@ typing-extensions==4.6.3 # astroid # grimp # import-linter + # kombu + # pylint + # rich tzdata==2023.3 # via # -r requirements/test.txt + # backports-zoneinfo # celery +uritemplate==4.1.1 + # via + # -r requirements/test.txt + # drf-yasg urllib3==2.0.4 # via # -r requirements/test.txt @@ -369,4 +395,6 @@ webencodings==0.5.1 wrapt==1.15.0 # via astroid zipp==3.15.0 - # via importlib-metadata + # via + # importlib-metadata + # importlib-resources diff --git a/requirements/test.txt b/requirements/test.txt index ba50193f..26d4a965 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -14,6 +14,11 @@ asgiref==3.7.2 # django attrs==23.1.0 # via -r requirements/base.txt +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/base.txt + # celery + # kombu billiard==4.1.0 # via # -r requirements/base.txt @@ -75,6 +80,7 @@ ddt==1.6.0 # django-waffle # djangorestframework # drf-jwt + # drf-yasg # edx-django-utils # edx-drf-extensions django-crum==0.7.9 @@ -92,11 +98,14 @@ djangorestframework==3.14.0 # via # -r requirements/base.txt # drf-jwt + # drf-yasg # edx-drf-extensions drf-jwt==1.19.2 # via # -r requirements/base.txt # edx-drf-extensions +drf-yasg==1.21.7 + # via -r requirements/base.txt edx-django-utils==5.7.0 # via # -r requirements/base.txt @@ -117,6 +126,10 @@ idna==3.4 # requests import-linter==1.9.0 # via -r requirements/test.in +inflection==0.5.1 + # via + # -r requirements/base.txt + # drf-yasg iniconfig==2.0.0 # via pytest jinja2==3.1.2 @@ -136,7 +149,10 @@ newrelic==8.9.0 # -r requirements/base.txt # edx-django-utils packaging==23.1 - # via pytest + # via + # -r requirements/base.txt + # drf-yasg + # pytest pbr==5.11.1 # via # -r requirements/base.txt @@ -189,8 +205,12 @@ pytz==2023.3 # -r requirements/base.txt # django # djangorestframework -pyyaml==6.0 - # via code-annotations + # drf-yasg +pyyaml==6.0.1 + # via + # -r requirements/base.txt + # code-annotations + # drf-yasg requests==2.31.0 # via # -r requirements/base.txt @@ -230,10 +250,16 @@ typing-extensions==4.6.3 # asgiref # grimp # import-linter + # kombu tzdata==2023.3 # via # -r requirements/base.txt + # backports-zoneinfo # celery +uritemplate==4.1.1 + # via + # -r requirements/base.txt + # drf-yasg urllib3==2.0.4 # via # -r requirements/base.txt diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index 42639219..c5de0ff4 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -1,6 +1,7 @@ """ Test the tagging APIs """ import ddt +from django.db import IntegrityError from django.test.testcases import TestCase, override_settings import openedx_tagging.core.tagging.api as tagging_api @@ -259,7 +260,7 @@ def test_tag_object(self): assert ( list( tagging_api.get_object_tags( - taxonomy=self.taxonomy, + taxonomy_id=self.taxonomy.pk, object_id="biology101", ) ) @@ -333,9 +334,90 @@ def test_tag_object_invalid_tag(self): ["Eukaryota Xenomorph"], "biology101", ) - assert "Invalid object tag for taxonomy (1): Eukaryota Xenomorph" in str( - exc.exception + assert "Invalid object tag for taxonomy (1): Eukaryota Xenomorph" in str(exc.exception) + + def test_tag_object_string(self): + with self.assertRaises(ValueError) as exc: + tagging_api.tag_object( + self.taxonomy, + 'string', + "biology101", + ) + assert "Tags must be a list, not str." in str(exc.exception) + + def test_tag_object_integer(self): + with self.assertRaises(ValueError) as exc: + tagging_api.tag_object( + self.taxonomy, + 1, + "biology101", + ) + assert "Tags must be a list, not int." in str(exc.exception) + + def test_tag_object_same_id(self): + # Tag the object with the same value twice + tagging_api.tag_object( + self.taxonomy, + [self.eubacteria.id], + "biology101", + ) + tagging_api.tag_object( + self.taxonomy, + [self.eubacteria.id], + "biology101", + ) + tagging_api.tag_object( + self.taxonomy, + ["Eubacteria"], + "biology101", + ) + + def test_tag_object_same_value(self): + # Tag the object with the same value twice + tagging_api.tag_object( + self.taxonomy, + ["Eubacteria"], + "biology101", + ) + tagging_api.tag_object( + self.taxonomy, + ["Eubacteria"], + "biology101", + ) + + def test_tag_object_same_id_multiple(self): + self.taxonomy.allow_multiple = True + self.taxonomy.save() + # Tag the object with the same value twice + object_tags = tagging_api.tag_object( + self.taxonomy, + [self.eubacteria.id, self.eubacteria.id], + "biology101", ) + assert len(object_tags) == 1 + + def test_tag_object_same_value_multiple(self): + self.taxonomy.allow_multiple = True + self.taxonomy.save() + # Tag the object with the same value twice + object_tags = tagging_api.tag_object( + self.taxonomy, + ["Eubacteria", "Eubacteria"], + "biology101", + ) + assert len(object_tags) == 1 + + def test_tag_object_same_value_multiple_free(self): + self.taxonomy.allow_multiple = True + self.taxonomy.allow_free_text = True + self.taxonomy.save() + # Tag the object with the same value twice + object_tags = tagging_api.tag_object( + self.taxonomy, + ["tag1", "tag1"], + "biology101", + ) + assert len(object_tags) == 1 @override_settings(LANGUAGES=test_languages) def test_tag_object_language_taxonomy(self): @@ -355,7 +437,7 @@ def test_tag_object_language_taxonomy(self): assert ( list( tagging_api.get_object_tags( - taxonomy=self.language_taxonomy, + taxonomy_id=self.language_taxonomy.pk, object_id="biology101", ) ) @@ -401,7 +483,7 @@ def test_tag_object_model_system_taxonomy(self): assert ( list( tagging_api.get_object_tags( - taxonomy=self.user_taxonomy, + taxonomy_id=self.user_taxonomy.pk, object_id="biology101", ) ) @@ -445,37 +527,17 @@ def test_get_object_tags(self): 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, + taxonomy_id=self.taxonomy.pk, ) ) == [ beta, diff --git a/tests/openedx_tagging/core/tagging/test_rules.py b/tests/openedx_tagging/core/tagging/test_rules.py index 415ae193..aec5a5a4 100644 --- a/tests/openedx_tagging/core/tagging/test_rules.py +++ b/tests/openedx_tagging/core/tagging/test_rules.py @@ -92,7 +92,7 @@ def test_view_taxonomy_enabled(self, enabled): assert self.staff.has_perm("oel_tagging.view_taxonomy", self.taxonomy) assert self.learner.has_perm("oel_tagging.view_taxonomy") assert ( - self.learner.has_perm("oel_tagging.view_taxonomy", self.taxonomy) == enabled + self.learner.has_perm( "oel_tagging.view_taxonomy", self.taxonomy) == enabled ) # Tag @@ -122,18 +122,6 @@ def test_tag_free_text_taxonomy(self, perm): assert not self.staff.has_perm(perm, self.bacteria) assert not self.learner.has_perm(perm, self.bacteria) - @ddt.data( - "oel_tagging.add_tag", - "oel_tagging.change_tag", - "oel_tagging.delete_tag", - ) - def test_tag_no_taxonomy(self, perm): - """Taxonomy administrators can modify any Tag, even those with no Taxonnmy.""" - tag = Tag() - assert self.superuser.has_perm(perm, tag) - assert self.staff.has_perm(perm, tag) - assert not self.learner.has_perm(perm, tag) - @ddt.data( True, False, @@ -161,64 +149,52 @@ def test_view_tag(self): # ObjectTag @ddt.data( - "oel_tagging.add_object_tag", - "oel_tagging.change_object_tag", + "oel_tagging.add_objecttag", + "oel_tagging.change_objecttag", ) def test_add_change_object_tag(self, perm): - """Taxonomy administrators can create/edit an ObjectTag with an enabled Taxonomy""" + """ + Everyone can create/edit an ObjectTag with an enabled Taxonomy + """ assert self.superuser.has_perm(perm) - assert self.superuser.has_perm(perm, self.object_tag) + assert self.superuser.has_perm(perm, self.object_tag.taxonomy) assert self.staff.has_perm(perm) - assert self.staff.has_perm(perm, self.object_tag) - assert not self.learner.has_perm(perm) - assert not self.learner.has_perm(perm, self.object_tag) + assert self.staff.has_perm(perm, self.object_tag.taxonomy) + assert self.learner.has_perm(perm) + assert self.learner.has_perm(perm, self.object_tag.taxonomy) @ddt.data( - "oel_tagging.add_object_tag", - "oel_tagging.change_object_tag", + "oel_tagging.add_objecttag", + "oel_tagging.change_objecttag", ) def test_object_tag_disabled_taxonomy(self, perm): - """Taxonomy administrators cannot create/edit an ObjectTag with a disabled Taxonomy""" + """Only Taxonomy administrators can create/edit an ObjectTag with a disabled Taxonomy""" self.taxonomy.enabled = False self.taxonomy.save() - assert self.superuser.has_perm(perm, self.object_tag) - assert not self.staff.has_perm(perm, self.object_tag) - assert not self.learner.has_perm(perm, self.object_tag) + assert self.superuser.has_perm(perm, self.object_tag.taxonomy) + assert self.staff.has_perm(perm, self.object_tag.taxonomy) + assert not self.learner.has_perm(perm, self.object_tag.taxonomy) @ddt.data( True, False, ) - def test_delete_object_tag(self, enabled): + def test_delete_objecttag(self, enabled): """Taxonomy administrators can delete any ObjectTag, even those associated with a disabled Taxonomy.""" self.taxonomy.enabled = enabled self.taxonomy.save() - assert self.superuser.has_perm("oel_tagging.delete_object_tag") - assert self.superuser.has_perm("oel_tagging.delete_object_tag", self.object_tag) - assert self.staff.has_perm("oel_tagging.delete_object_tag") - assert self.staff.has_perm("oel_tagging.delete_object_tag", self.object_tag) - assert not self.learner.has_perm("oel_tagging.delete_object_tag") - assert not self.learner.has_perm( - "oel_tagging.delete_object_tag", self.object_tag - ) - - @ddt.data( - "oel_tagging.add_object_tag", - "oel_tagging.change_object_tag", - "oel_tagging.delete_object_tag", - ) - def test_object_tag_no_taxonomy(self, perm): - """Taxonomy administrators can modify an ObjectTag with no Taxonomy""" - object_tag = ObjectTag() - assert self.superuser.has_perm(perm, object_tag) - assert self.staff.has_perm(perm, object_tag) - assert not self.learner.has_perm(perm, object_tag) + assert self.superuser.has_perm("oel_tagging.delete_objecttag") + assert self.superuser.has_perm("oel_tagging.delete_objecttag", self.object_tag.taxonomy) + assert self.staff.has_perm("oel_tagging.delete_objecttag") + assert self.staff.has_perm("oel_tagging.delete_objecttag", self.object_tag.taxonomy) + assert not self.learner.has_perm("oel_tagging.delete_objecttag") + assert not self.learner.has_perm("oel_tagging.delete_objecttag", self.object_tag.taxonomy) def test_view_object_tag(self): """Anyone can view any ObjectTag""" - assert self.superuser.has_perm("oel_tagging.view_object_tag") - assert self.superuser.has_perm("oel_tagging.view_object_tag", self.object_tag) - assert self.staff.has_perm("oel_tagging.view_object_tag") - assert self.staff.has_perm("oel_tagging.view_object_tag", self.object_tag) - assert self.learner.has_perm("oel_tagging.view_object_tag") - assert self.learner.has_perm("oel_tagging.view_object_tag", self.object_tag) + assert self.superuser.has_perm("oel_tagging.view_objecttag") + assert self.superuser.has_perm("oel_tagging.view_objecttag", self.object_tag) + assert self.staff.has_perm("oel_tagging.view_objecttag") + assert self.staff.has_perm("oel_tagging.view_objecttag", self.object_tag) + assert self.learner.has_perm("oel_tagging.view_objecttag") + assert self.learner.has_perm("oel_tagging.view_objecttag", self.object_tag) diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index 69805e23..04930e8e 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -2,13 +2,14 @@ Tests tagging rest api views """ +from urllib.parse import parse_qs, urlparse + import ddt from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.test import APITestCase -from urllib.parse import urlparse, parse_qs -from openedx_tagging.core.tagging.models import Taxonomy +from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy User = get_user_model() @@ -17,6 +18,12 @@ TAXONOMY_DETAIL_URL = "/tagging/rest_api/v1/taxonomies/{pk}/" +OBJECT_TAGS_RETRIEVE_URL = "/tagging/rest_api/v1/object_tags/{object_id}/" +OBJECT_TAGS_UPDATE_URL = "/tagging/rest_api/v1/object_tags/{object_id}/?taxonomy={taxonomy_id}" + +LANGUAGE_TAXONOMY_ID = -1 + + def check_taxonomy( data, id, @@ -376,3 +383,300 @@ def test_delete_taxonomy_404(self): self.client.force_authenticate(user=self.staff) response = self.client.delete(url) assert response.status_code, status.HTTP_404_NOT_FOUND + + +@ddt.ddt +class TestObjectTagViewSet(APITestCase): + """ + Testing various cases for the ObjectTagView. + """ + + def setUp(self): + super().setUp() + + self.user = User.objects.create( + username="user", + email="user@example.com", + ) + + self.staff = User.objects.create( + username="staff", + email="staff@example.com", + is_staff=True, + ) + + # System-defined language taxonomy with valid ObjectTag + self.system_taxonomy = SystemDefinedTaxonomy.objects.create(name="System Taxonomy") + self.tag1 = Tag.objects.create(taxonomy=self.system_taxonomy, value="Tag 1") + ObjectTag.objects.create(object_id="abc", taxonomy=self.system_taxonomy, tag=self.tag1) + + # Language system-defined language taxonomy + self.language_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + + # Closed Taxonomies created by taxonomy admins, each with 20 ObjectTags + self.enabled_taxonomy = Taxonomy.objects.create(name="Enabled Taxonomy") + self.disabled_taxonomy = Taxonomy.objects.create(name="Disabled Taxonomy", enabled=False) + self.multiple_taxonomy = Taxonomy.objects.create(name="Multiple Taxonomy", allow_multiple=True) + for i in range(20): + # Valid ObjectTags + tag_enabled = Tag.objects.create(taxonomy=self.enabled_taxonomy, value=f"Tag {i}") + tag_disabled = Tag.objects.create(taxonomy=self.disabled_taxonomy, value=f"Tag {i}") + tag_multiple = Tag.objects.create(taxonomy=self.multiple_taxonomy, value=f"Tag {i}") + ObjectTag.objects.create( + object_id="abc", taxonomy=self.enabled_taxonomy, tag=tag_enabled, _value=tag_enabled.value + ) + ObjectTag.objects.create( + object_id="abc", taxonomy=self.disabled_taxonomy, tag=tag_disabled, _value=tag_disabled.value + ) + ObjectTag.objects.create( + object_id="abc", taxonomy=self.multiple_taxonomy, tag=tag_multiple, _value=tag_multiple.value + ) + + # Free-Text Taxonomies created by taxonomy admins, each linked + # to 200 ObjectTags + self.open_taxonomy_enabled = Taxonomy.objects.create(name="Enabled Free-Text Taxonomy", allow_free_text=True) + self.open_taxonomy_disabled = Taxonomy.objects.create( + name="Disabled Free-Text Taxonomy", allow_free_text=True, enabled=False + ) + for i in range(200): + ObjectTag.objects.create(object_id="abc", taxonomy=self.open_taxonomy_enabled, _value=f"Free Text {i}") + ObjectTag.objects.create(object_id="abc", taxonomy=self.open_taxonomy_disabled, _value=f"Free Text {i}") + + @ddt.data( + (None, "abc", status.HTTP_403_FORBIDDEN, None, None), + ("user", "abc", status.HTTP_200_OK, 461, 10), + ("staff", "abc", status.HTTP_200_OK, 461, 10), + (None, "non-existing-id", status.HTTP_403_FORBIDDEN, None, None), + ("user", "non-existing-id", status.HTTP_200_OK, 0, 0), + ("staff", "non-existing-id", status.HTTP_200_OK, 0, 0), + ) + @ddt.unpack + def test_retrieve_object_tags(self, user_attr, object_id, expected_status, expected_count, expected_results): + """ + Test retrieving object tags + """ + url = OBJECT_TAGS_RETRIEVE_URL.format(object_id=object_id) + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url) + assert response.status_code == expected_status + + if status.is_success(expected_status): + assert response.data.get("count") == expected_count + assert response.data.get("results") is not None + assert len(response.data.get("results")) == expected_results + + @ddt.data( + (None, "abc", status.HTTP_403_FORBIDDEN, None, None), + ("user", "abc", status.HTTP_200_OK, 20, 10), + ("staff", "abc", status.HTTP_200_OK, 20, 10), + ) + @ddt.unpack + def test_retrieve_object_tags_taxonomy_queryparam( + self, user_attr, object_id, expected_status, expected_count, expected_results + ): + """ + Test retrieving object tags for specific taxonomies provided + """ + url = OBJECT_TAGS_RETRIEVE_URL.format(object_id=object_id) + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url, {"taxonomy": self.enabled_taxonomy.pk}) + assert response.status_code == expected_status + if status.is_success(expected_status): + assert response.data.get("count") == expected_count + assert response.data.get("results") is not None + assert len(response.data.get("results")) == expected_results + object_tags = response.data.get("results") + for object_tag in object_tags: + assert object_tag.get("is_valid") is True + assert object_tag.get("taxonomy_id") == self.enabled_taxonomy.pk + + @ddt.data( + (None, "abc", status.HTTP_403_FORBIDDEN), + ("user", "abc", status.HTTP_400_BAD_REQUEST), + ("staff", "abc", status.HTTP_400_BAD_REQUEST), + ) + @ddt.unpack + def test_retrieve_object_tags_invalid_taxonomy_queryparam(self, user_attr, object_id, expected_status): + """ + Test retrieving object tags for invalid taxonomy + """ + url = OBJECT_TAGS_RETRIEVE_URL.format(object_id=object_id) + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + # Invalid Taxonomy + response = self.client.get(url, {"taxonomy": 123123}) + assert response.status_code == expected_status + + @ddt.data( + # Page 1, default page size 10, total count 200, returns 10 results + (None, 1, None, status.HTTP_403_FORBIDDEN, None, None), + ("user", 1, None, status.HTTP_200_OK, 200, 10), + ("staff", 1, None, status.HTTP_200_OK, 200, 10), + # Page 2, default page size 10, total count 200, returns 10 results + (None, 2, None, status.HTTP_403_FORBIDDEN, None, None), + ("user", 2, None, status.HTTP_200_OK, 200, 10), + ("staff", 2, None, status.HTTP_200_OK, 200, 10), + # Page 21, default page size 10, total count 200, no more results + (None, 21, None, status.HTTP_403_FORBIDDEN, None, None), + ("user", 21, None, status.HTTP_404_NOT_FOUND, None, None), + ("staff", 21, None, status.HTTP_404_NOT_FOUND, None, None), + # Page 3, page size 2, total count 200, returns 2 results + (None, 3, 2, status.HTTP_403_FORBIDDEN, 200, 2), + ("user", 3, 2, status.HTTP_200_OK, 200, 2), + ("staff", 3, 2, status.HTTP_200_OK, 200, 2), + ) + @ddt.unpack + def test_retrieve_object_tags_pagination( + self, user_attr, page, page_size, expected_status, expected_count, expected_results + ): + """ + Test pagination for retrieve object tags + """ + url = OBJECT_TAGS_RETRIEVE_URL.format(object_id="abc") + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + query_params = {"taxonomy": self.open_taxonomy_enabled.pk, "page": page} + if page_size: + query_params["page_size"] = page_size + + response = self.client.get(url, query_params) + assert response.status_code == expected_status + if status.is_success(expected_status): + assert response.data.get("count") == expected_count + assert response.data.get("results") is not None + assert len(response.data.get("results")) == expected_results + object_tags = response.data.get("results") + for object_tag in object_tags: + assert object_tag.get("taxonomy_id") == self.open_taxonomy_enabled.pk + + @ddt.data( + (None, "POST", status.HTTP_403_FORBIDDEN), + (None, "PATCH", status.HTTP_403_FORBIDDEN), + (None, "DELETE", status.HTTP_403_FORBIDDEN), + ("user", "POST", status.HTTP_405_METHOD_NOT_ALLOWED), + ("user", "PATCH", status.HTTP_405_METHOD_NOT_ALLOWED), + ("user", "DELETE", status.HTTP_403_FORBIDDEN), + ("staff", "POST", status.HTTP_405_METHOD_NOT_ALLOWED), + ("staff", "PATCH", status.HTTP_405_METHOD_NOT_ALLOWED), + ("staff", "DELETE", status.HTTP_405_METHOD_NOT_ALLOWED), + ) + @ddt.unpack + def test_object_tags_remaining_http_methods( + self, + user_attr, + http_method, + expected_status, + ): + """ + Test POST/PATCH/DELETE method for ObjectTagView + + Only staff users should have permissions to perform the actions, + however the methods are currently not allowed. + """ + url = OBJECT_TAGS_RETRIEVE_URL.format(object_id="abc") + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + if http_method == "POST": + response = self.client.post(url, {"test": "payload"}, format="json") + elif http_method == "PATCH": + response = self.client.patch(url, {"test": "payload"}, format="json") + elif http_method == "DELETE": + response = self.client.delete(url) + + assert response.status_code == expected_status + + @ddt.data( + (None, "language_taxonomy", ["Portuguese"], status.HTTP_403_FORBIDDEN), + ("user", "language_taxonomy", ["Portuguese"], status.HTTP_200_OK), + ("staff", "language_taxonomy", ["Portuguese"], status.HTTP_200_OK), + (None, "language_taxonomy", ["Portuguese", "English"], status.HTTP_403_FORBIDDEN), + ("user", "language_taxonomy", ["Portuguese", "English"], status.HTTP_400_BAD_REQUEST), + ("staff", "language_taxonomy", ["Portuguese", "English"], status.HTTP_400_BAD_REQUEST), + (None, "language_taxonomy", ["Invalid"], status.HTTP_403_FORBIDDEN), + ("user", "language_taxonomy", ["Invalid"], status.HTTP_400_BAD_REQUEST), + ("staff", "language_taxonomy", ["Invalid"], status.HTTP_400_BAD_REQUEST), + (None, "language_taxonomy", [], status.HTTP_403_FORBIDDEN), + ("user", "language_taxonomy", [], status.HTTP_400_BAD_REQUEST), # Language taxonomy have required=True + ("staff", "language_taxonomy", [], status.HTTP_400_BAD_REQUEST), # Language taxonomy have required=True + (None, "enabled_taxonomy", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("user", "enabled_taxonomy", ["Tag 1"], status.HTTP_200_OK), + ("staff", "enabled_taxonomy", ["Tag 1"], status.HTTP_200_OK), + (None, "enabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("user", "enabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_400_BAD_REQUEST), + ("staff", "enabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_400_BAD_REQUEST), + (None, "enabled_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), + ("user", "enabled_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), + ("staff", "enabled_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), + (None, "enabled_taxonomy", [], status.HTTP_403_FORBIDDEN), + ("user", "enabled_taxonomy", [], status.HTTP_200_OK), + ("staff", "enabled_taxonomy", [], status.HTTP_200_OK), + (None, "disabled_taxonomy", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("user", "disabled_taxonomy", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staff", "disabled_taxonomy", ["Tag 1"], status.HTTP_200_OK), + (None, "disabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("user", "disabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staff", "disabled_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_400_BAD_REQUEST), + (None, "disabled_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), + ("user", "disabled_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), + ("staff", "disabled_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), + (None, "disabled_taxonomy", [], status.HTTP_403_FORBIDDEN), + ("user", "disabled_taxonomy", [], status.HTTP_403_FORBIDDEN), + ("staff", "disabled_taxonomy", [], status.HTTP_200_OK), + (None, "multiple_taxonomy", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("user", "multiple_taxonomy", ["Tag 1"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1"], status.HTTP_200_OK), + (None, "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + (None, "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), + ("user", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), + ("staff", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), + (None, "multiple_taxonomy", [], status.HTTP_403_FORBIDDEN), + ("user", "multiple_taxonomy", [], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", [], status.HTTP_200_OK), + (None, "open_taxonomy_enabled", ["tag1"], status.HTTP_403_FORBIDDEN), + ("user", "open_taxonomy_enabled", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy_enabled", ["tag1"], status.HTTP_200_OK), + (None, "open_taxonomy_enabled", ["multiple", "invalid"], status.HTTP_403_FORBIDDEN), + ("user", "open_taxonomy_enabled", ["multiple", "invalid"], status.HTTP_400_BAD_REQUEST), + ("staff", "open_taxonomy_enabled", ["multiple", "invalid"], status.HTTP_400_BAD_REQUEST), + (None, "open_taxonomy_disabled", ["tag1"], status.HTTP_403_FORBIDDEN), + ("user", "open_taxonomy_disabled", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staff", "open_taxonomy_disabled", ["tag1"], status.HTTP_200_OK), + (None, "open_taxonomy_disabled", [], status.HTTP_403_FORBIDDEN), + ("user", "open_taxonomy_disabled", [], status.HTTP_403_FORBIDDEN), + ("staff", "open_taxonomy_disabled", [], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_object(self, user_attr, taxonomy_attr, tag_values, expected_status): + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + url = OBJECT_TAGS_UPDATE_URL.format(object_id="abc", taxonomy_id=taxonomy.pk) + + response = self.client.put(url, {"tags": tag_values}, format="json") + assert response.status_code == expected_status + if status.is_success(expected_status): + assert len(response.data.get("results")) == len(tag_values) + assert set(t["value"] for t in response.data["results"]) == set(tag_values) +