diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 5ca23c48..c292681d 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -68,6 +68,21 @@ def get_taxonomies(enabled=True) -> QuerySet: return queryset.filter(enabled=enabled) +def update_taxonomy(id: int, **update_data) -> Union[Taxonomy, None]: + """ + Updates a Taxonomy which has the given ID. + """ + return Taxonomy.objects.filter(id=id).update(**update_data) + + +def delete_taxonomy(id: int): + """ + Deletes a Taxonomy which has the given ID. + """ + taxonomy = Taxonomy.objects.get(id=id) + taxonomy.delete() + + def get_tags(taxonomy: Taxonomy) -> List[Tag]: """ Returns a list of predefined tags for the given taxonomy. diff --git a/openedx_tagging/core/tagging/rest_api/urls.py b/openedx_tagging/core/tagging/rest_api/urls.py new file mode 100644 index 00000000..d7f012bb --- /dev/null +++ b/openedx_tagging/core/tagging/rest_api/urls.py @@ -0,0 +1,9 @@ +""" +Taxonomies API URLs. +""" + +from django.urls import path, include + +from .v1 import urls as v1_urls + +urlpatterns = [path("v1/", include(v1_urls))] diff --git a/openedx_tagging/core/tagging/rest_api/v1/__init__.py b/openedx_tagging/core/tagging/rest_api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_tagging/core/tagging/rest_api/v1/permissions.py b/openedx_tagging/core/tagging/rest_api/v1/permissions.py new file mode 100644 index 00000000..106c2491 --- /dev/null +++ b/openedx_tagging/core/tagging/rest_api/v1/permissions.py @@ -0,0 +1,30 @@ +""" +Taxonomy permissions +""" + +from rest_framework.permissions import DjangoObjectPermissions + + +class TaxonomyObjectPermissions(DjangoObjectPermissions): + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + # 'GET': [], + 'OPTIONS': [], + # 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'HEAD': [], + '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'], + } + + def has_permission(self, request, view): + # Workaround to allow 'retrieve' view to pass through the + # method GET permission check. Actual object permission check + # is done in the get_object() method. + if view.action == 'retrieve': + return bool(request.user and request.user.is_authenticated) + else: + return super().has_permission(request, view) + + diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py new file mode 100644 index 00000000..d4cc6d61 --- /dev/null +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -0,0 +1,72 @@ +""" +API Serializers for taxonomies +""" + +from django.utils.module_loading import import_string +from rest_framework import serializers + +from openedx_tagging.core.tagging.models import Taxonomy + +class TaxonomyListQueryParamsSerializer(serializers.Serializer): + """ + Serializer for the query params for the GET view + """ + + enabled = serializers.BooleanField(required=False) + +class ClassField(serializers.Field): + """ + Class field + """ + def to_representation(self, class_type): + if class_type: + # ref: https://stackoverflow.com/a/2020083 + return ".".join( + [class_type.__module__, class_type.__qualname__] + ) + return None + + def to_internal_value(self, value): + if value: + try: + # Ensure that the class exists + return import_string(value) + except (ImportError, AttributeError): + raise serializers.ValidationError( + f"Invalid taxonomy_class: {value}." + ) + return value + +class TaxonomySerializer(serializers.ModelSerializer): + taxonomy_class = ClassField(required=False, allow_null=True) + class Meta: + model = Taxonomy + fields = [ + "id", + "name", + "description", + "enabled", + "required", + "allow_multiple", + "allow_free_text", + "system_defined", + "visible_to_authors", + "taxonomy_class", + ] + + def save(self): + raise NotImplementedError( + "Cannot save a taxonomy through serializer. Use the create_taxonomy or" + "update_taxonomy function instead." + ) + + def update(self): + raise NotImplementedError( + "Cannot save a taxonomy through serializer. Use the create_taxonomy or" + "update_taxonomy function instead." + ) + def create(self): + raise NotImplementedError( + "Cannot save a taxonomy through serializer. Use the create_taxonomy or" + "update_taxonomy function instead." + ) diff --git a/openedx_tagging/core/tagging/rest_api/v1/urls.py b/openedx_tagging/core/tagging/rest_api/v1/urls.py new file mode 100644 index 00000000..97d653a2 --- /dev/null +++ b/openedx_tagging/core/tagging/rest_api/v1/urls.py @@ -0,0 +1,16 @@ +""" +Taxonomies API v1 URLs. +""" + +from rest_framework.routers import DefaultRouter + +from django.urls.conf import path, include + +from . import views + +router = DefaultRouter() +router.register("taxonomies", views.TaxonomyView, basename="taxonomy") + +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 new file mode 100644 index 00000000..e8d641f6 --- /dev/null +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -0,0 +1,180 @@ +""" +Tagging API Views +""" +from django.http import Http404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from ...api import ( + create_taxonomy, + delete_taxonomy, + get_taxonomy, + get_taxonomies, + update_taxonomy, +) +from .serializers import TaxonomyListQueryParamsSerializer, TaxonomySerializer +from .permissions import TaxonomyObjectPermissions + + +class TaxonomyView(ModelViewSet): + """ + View to list, create, retrieve, update, or delete Taxonomies. + + NOTE: Only the GET request requires a request parameter, otherwise pass the uuid as part + of the post body + + **List Query Parameters** + * enabled (optional) - Filter by enabled status (default: None). Valid values: true, false, 1, 0, "true", "false", "1" + + **List Example Requests** + GET api/tagging/v1/taxonomy - Get all taxonomies + GET api/tagging/v1/taxonomy?enabled=true - Get all enabled taxonomies + GET api/tagging/v1/taxonomy?enabled=false - Get all disabled taxonomies + + **List Query Returns** + * 200 - Success + * 400 - Invalid query parameter + * 403 - Permission denied + + **Retrieve Parameters** + * id (required): - The id of the taxonomy to retrieve + + **Retrieve Example Requests** + GET api/tagging/v1/taxonomy/:id - Get a specific taxonomy + + **Retrieve Query Returns** + * 200 - Success + * 404 - Taxonomy not found or User does not have permission to access the taxonomy + + **Create Parameters** + * name (required): User-facing label used when applying tags from this taxonomy to Open edX objects. + * description (optional): Provides extra information for the user when applying tags from this taxonomy to an object. + * enabled (optional): Only enabled taxonomies will be shown to authors (default: true). + * required (optional): Indicates that one or more tags from this taxonomy must be added to an object (default: False). + * allow_multiple (optional): Indicates that multiple tags from this taxonomy may be added to an object (default: False). + * allow_free_text (optional): Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values (default: False). + * taxonomy_class (optional): Taxonomy subclass used to instantiate this instance. Must be a fully-qualified + module and class name (default: None). + + **Create Example Requests** + POST api/tagging/v1/taxonomy - Create a taxonomy + { + "name": "Taxonomy Name", - User-facing label used when applying tags from this taxonomy to Open edX objects." + "description": "This is a description", + "enabled": True, + "required": True, + "allow_multiple": True, + "allow_free_text": True, + "taxonomy_class": "class", #ToDo: add a example here + } + + + **Create Query Returns** + * 201 - Success + * 403 - Permission denied + + **Update Parameters** + * id (required): - The id of the taxonomy to update + + **Update Request Body** + * name (optional): User-facing label used when applying tags from this taxonomy to Open edX objects. + * description (optional): Provides extra information for the user when applying tags from this taxonomy to an object. + * enabled (optional): Only enabled taxonomies will be shown to authors. + * required (optional): Indicates that one or more tags from this taxonomy must be added to an object. + * allow_multiple (optional): Indicates that multiple tags from this taxonomy may be added to an object. + * allow_free_text (optional): Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values. + * taxonomy_class (optional): Taxonomy subclass used to instantiate this instance. Must be a fully-qualified + module and class name. + + **Update Example Requests** + PUT api/tagging/v1/taxonomy/:id - Update a taxonomy + { + "name": "Taxonomy New Name", + "description": "This is a new description", + "enabled": False, + "required": False, + "allow_multiple": False, + "allow_free_text": True, + "taxonomy_class": "class", #ToDo: add a example here + } + PATCH api/tagging/v1/taxonomy/:id - Partially update a taxonomy + { + "name": "Taxonomy New Name", + } + + **Update Query Returns** + * 200 - Success + * 403 - Permission denied + + **Delete Parameters** + * id (required): - The id of the taxonomy to delete + + **Delete Example Requests** + DELETE api/tagging/v1/taxonomy/:id - Delete a taxonomy + + **Delete Query Returns** + * 200 - Success + * 404 - Taxonomy not found + * 403 - Permission denied + + """ + + + serializer_class = TaxonomySerializer + lookup_field = "id" + permission_classes = [TaxonomyObjectPermissions] + + def get_object(self): + """ + Return the requested taxonomy object, if the user has appropriate + permissions. + """ + id = self.kwargs.get("id") + taxonomy = get_taxonomy(id) + if not taxonomy: + raise Http404("Taxonomy not found") + self.check_object_permissions(self.request, taxonomy) + + return taxonomy + + def get_queryset(self): + """ + Return a list of taxonomies. If you want the disabled taxonomies, pass enabled=False. If you want all taxonomies (both enabled and disabled), pass enabled=None. + """ + query_params = TaxonomyListQueryParamsSerializer( + data=self.request.query_params.dict() + ) + query_params.is_valid(raise_exception=True) + enabled = query_params.data.get("enabled", None) + + return get_taxonomies(enabled) + + def create(self, request): + """ + Create a new taxonomy. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + taxonomy = create_taxonomy(**serializer.validated_data) + return Response( + self.serializer_class(taxonomy).data, status=status.HTTP_201_CREATED + ) + + def update(self, request, *args, **kwargs): + """ + Update a taxonomy. + """ + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + update_taxonomy(instance.id, **serializer.validated_data) + + return Response(serializer.data) + + def perform_destroy(self, instance): + """ + Delete a taxonomy. + """ + delete_taxonomy(instance.id) diff --git a/openedx_tagging/core/tagging/rules.py b/openedx_tagging/core/tagging/rules.py index 72b9fa6b..052dfdf8 100644 --- a/openedx_tagging/core/tagging/rules.py +++ b/openedx_tagging/core/tagging/rules.py @@ -28,7 +28,7 @@ def can_change_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool: Even taxonomy admins cannot change system taxonomies. """ return is_taxonomy_admin(user) and ( - not taxonomy or not taxonomy or (taxonomy and not taxonomy.system_defined) + not taxonomy or (taxonomy and not taxonomy.system_defined) ) diff --git a/openedx_tagging/core/tagging/urls.py b/openedx_tagging/core/tagging/urls.py new file mode 100644 index 00000000..da2c5208 --- /dev/null +++ b/openedx_tagging/core/tagging/urls.py @@ -0,0 +1,10 @@ +""" +Tagging API URLs. +""" + +from django.urls import path, include + +from .rest_api import urls + +app_name = "oel_tagging" +urlpatterns = [path("", include(urls))] diff --git a/projects/urls.py b/projects/urls.py index e27da753..95c8be22 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,5 +9,6 @@ path("admin/", admin.site.urls), path("media_server/", include("openedx_learning.contrib.media_server.urls")), path("rest_api/", include("openedx_learning.rest_api.urls")), + path("tagging/rest_api/", include("openedx_tagging.core.tagging.urls", namespace="oel_tagging")), path('__debug__/', include('debug_toolbar.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements/dev.txt b/requirements/dev.txt index add9cf1d..48cf4fb4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -81,7 +81,9 @@ django==3.2.19 # djangorestframework # edx-i18n-tools django-debug-toolbar==4.1.0 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/quality.txt djangorestframework==3.14.0 # via -r requirements/quality.txt docutils==0.20.1 diff --git a/requirements/doc.txt b/requirements/doc.txt index d9a4c3c2..f63eb911 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -41,8 +41,11 @@ django==3.2.19 # via # -c requirements/constraints.txt # -r requirements/test.txt + # django-debug-toolbar # djangorestframework # sphinxcontrib-django +django-debug-toolbar==4.1.0 + # via -r requirements/test.txt djangorestframework==3.14.0 # via -r requirements/test.txt doc8==1.1.1 @@ -175,6 +178,7 @@ sqlparse==0.4.4 # via # -r requirements/test.txt # django + # django-debug-toolbar stevedore==5.1.0 # via # -r requirements/test.txt diff --git a/requirements/quality.txt b/requirements/quality.txt index c580bd7e..425cfa51 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -47,7 +47,10 @@ django==3.2.19 # via # -c requirements/constraints.txt # -r requirements/test.txt + # django-debug-toolbar # djangorestframework +django-debug-toolbar==4.1.0 + # via -r requirements/test.txt djangorestframework==3.14.0 # via -r requirements/test.txt docutils==0.20.1 @@ -198,6 +201,7 @@ sqlparse==0.4.4 # via # -r requirements/test.txt # django + # django-debug-toolbar stevedore==5.1.0 # via # -r requirements/test.txt diff --git a/requirements/test.in b/requirements/test.in index 1d521351..50feee66 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -14,3 +14,4 @@ pytest-django # pytest extension for better Django support code-annotations # provides commands used by the pii_check make target. ddt # supports data driven tests mock # supports overriding classes and methods in tests +django-debug-toolbar # provides a debug toolbar for Django diff --git a/requirements/test.txt b/requirements/test.txt index e64d8190..22c43aed 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -23,7 +23,10 @@ ddt==1.6.0 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-debug-toolbar # djangorestframework +django-debug-toolbar==4.1.0 + # via -r requirements/test.in djangorestframework==3.14.0 # via -r requirements/base.txt exceptiongroup==1.1.1 @@ -72,6 +75,7 @@ sqlparse==0.4.4 # via # -r requirements/base.txt # django + # django-debug-toolbar stevedore==5.1.0 # via code-annotations text-unidecode==1.3 diff --git a/test_settings.py b/test_settings.py index 2e41694e..741f572e 100644 --- a/test_settings.py +++ b/test_settings.py @@ -33,8 +33,9 @@ def root(*args): "django.contrib.sessions", "django.contrib.staticfiles", # Admin - # 'django.contrib.admin', - # 'django.contrib.admindocs', + 'django.contrib.admin', + 'django.contrib.admindocs', + 'debug_toolbar', # django-rules based authorization 'rules.apps.AutodiscoverRulesConfig', # Our own apps diff --git a/tests/openedx_tagging/core/tagging/test_serializer.py b/tests/openedx_tagging/core/tagging/test_serializer.py new file mode 100644 index 00000000..902b0544 --- /dev/null +++ b/tests/openedx_tagging/core/tagging/test_serializer.py @@ -0,0 +1,41 @@ +""" +Tests tagging rest api serializers +""" + +import ddt +from django.test import TestCase + +from openedx_tagging.core.tagging.rest_api.v1.serializers import TaxonomySerializer +from openedx_tagging.core.tagging.models import Taxonomy + + +@ddt.ddt +class TestTaxonomySerializer(TestCase): + def test_save_exception(self): + serialize = TaxonomySerializer(data={"name": "Test Taxonomy"}) + + self.assertRaises(NotImplementedError, serialize.save) + + def test_create_exception(self): + serialize = TaxonomySerializer(data={"name": "Test Taxonomy"}) + self.assertRaises(NotImplementedError, serialize.create) + + def test_update_exception(self): + serialize = TaxonomySerializer(data={"name": "Test Taxonomy"}) + self.assertRaises(NotImplementedError, serialize.update) + + @ddt.data( + (None, True), + ("openedx_tagging.core.tagging.models.Taxonomy", True), + ("invalid.Class", False), + ) + @ddt.unpack + def test_serialize_taxonomy_class(self, taxonomy_class, expected_valid): + taxonomy_data = {"name": "Test Taxonomy", "taxonomy_class": taxonomy_class} + serialize = TaxonomySerializer( + data=taxonomy_data + ) + self.assertEqual(serialize.is_valid(), expected_valid) + if expected_valid: + self.assertGreaterEqual(serialize.data.items(), taxonomy_data.items()) + diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py new file mode 100644 index 00000000..66adb837 --- /dev/null +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -0,0 +1,299 @@ +""" +Tests tagging rest api views +""" + +import ddt +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from openedx_tagging.core.tagging.models import Taxonomy + +User = get_user_model() + + + +@ddt.ddt +class TestTaxonomyViewSet(APITestCase): + 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, + ) + + @ddt.data( + (None, status.HTTP_200_OK), + (1, status.HTTP_200_OK), + (0, status.HTTP_200_OK), + (True, status.HTTP_200_OK), + (False, status.HTTP_200_OK), + ("True", status.HTTP_200_OK), + ("False", status.HTTP_200_OK), + ("1", status.HTTP_200_OK), + ("0", status.HTTP_200_OK), + (2, status.HTTP_400_BAD_REQUEST), + ("invalid", status.HTTP_400_BAD_REQUEST), + ) + @ddt.unpack + def test_list_taxonomy_queryparams(self, enabled, expected_status): + url = reverse("oel_tagging:taxonomy-list") + Taxonomy.objects.create(name="Taxonomy enabled 1", enabled=True).save() + Taxonomy.objects.create(name="Taxonomy enabled 2", enabled=True).save() + Taxonomy.objects.create(name="Taxonomy disabled", enabled=False).save() + + self.client.force_authenticate(user=self.staff) + if enabled is not None: + response = self.client.get(url, {"enabled": enabled}) + else: + response = self.client.get(url) + self.assertEqual(response.status_code, expected_status) + + # If we were able to create the taxonomy, check if it was created + if status.is_success(expected_status): + if enabled is None: + self.assertEqual(len(response.data), 3) + elif enabled in [True, "True", 1, "1"]: + self.assertEqual(len(response.data), 2) + else: + self.assertEqual(len(response.data), 1) + + + @ddt.data( + (None, status.HTTP_403_FORBIDDEN), + ("user", status.HTTP_403_FORBIDDEN), + ("staff", status.HTTP_200_OK), + ) + @ddt.unpack + def test_list_taxonomy(self, user_attr, expected_status): + url = reverse("oel_tagging:taxonomy-list") + + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + response = self.client.get(url) + self.assertEqual(response.status_code, expected_status) + + @ddt.data( + (None, {"enabled": True}, status.HTTP_403_FORBIDDEN), + ("user", {"enabled": True}, status.HTTP_200_OK), + ("user", {"enabled": False}, status.HTTP_404_NOT_FOUND), + ("staff", {"enabled": True}, status.HTTP_200_OK), + ("staff", {"enabled": True}, status.HTTP_200_OK), + ) + @ddt.unpack + def test_detail_taxonomy(self, user_attr, taxonomy_data, expected_status): + create_data = {**{"name": "taxonomy detail test"}, **taxonomy_data} + taxonomy = Taxonomy.objects.create(**create_data) + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": taxonomy.id}) + + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + + response = self.client.get(url) + self.assertEqual(response.status_code, expected_status) + + if status.is_success(expected_status): + response = self.client.get(url) + self.assertGreaterEqual(response.data.items(), create_data.items()) + + def test_detail_taxonomy_404(self): + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": 123123}) + + self.client.force_authenticate(user=self.staff) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data( + (None, status.HTTP_403_FORBIDDEN), + ("user", status.HTTP_403_FORBIDDEN), + ("staff", status.HTTP_201_CREATED), + ) + @ddt.unpack + def test_create_taxonomy(self, user_attr, expected_status): + url = reverse("oel_tagging:taxonomy-list") + + create_data = { + "name": "taxonomy_data_2", + "description": "This is a description", + "enabled": False, + "required": True, + "allow_multiple": True, + } + + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + response = self.client.post(url, create_data) + self.assertEqual(response.status_code, expected_status) + + # If we were able to create the taxonomy, check if it was created + if status.is_success(expected_status): + self.assertGreaterEqual(response.data.items(), create_data.items()) + url = reverse( + "oel_tagging:taxonomy-detail", kwargs={"id": response.data["id"]} + ) + response = self.client.get(url) + self.assertGreaterEqual(response.data.items(), create_data.items()) + + def test_create_taxonomy_class(self): + url = reverse("oel_tagging:taxonomy-list") + + create_data = { + "name": "taxonomy_data_3", + "description": "This is a description", + "enabled": False, + "required": True, + "allow_multiple": True, + "taxonomy_class": 'openedx_tagging.core.tagging.models.Taxonomy' + } + + self.client.force_authenticate(user=self.staff) + response = self.client.post(url, create_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertGreaterEqual(response.data.items(), create_data.items()) + url = reverse( + "oel_tagging:taxonomy-detail", kwargs={"id": response.data["id"]} + ) + response = self.client.get(url) + self.assertGreaterEqual(response.data.items(), create_data.items()) + + @ddt.data( + {}, + {"name": "Error taxonomy 2", "required": "Invalid value"}, + {"name": "Error taxonomy 3", "enabled": "Invalid value"}, + ) + def test_create_taxonomy_error(self, create_data): + url = reverse("oel_tagging:taxonomy-list") + + self.client.force_authenticate(user=self.staff) + response = self.client.post(url, create_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @ddt.data({"name": "System defined taxonomy", "system_defined": True}) + def test_create_taxonomy_system_defined(self, create_data): + """ + Cannont create a taxonomy with system_defined=true + """ + url = reverse("oel_tagging:taxonomy-list") + + self.client.force_authenticate(user=self.staff) + response = self.client.post(url, create_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["system_defined"], False) + + @ddt.data( + (None, status.HTTP_403_FORBIDDEN), + ("user", status.HTTP_403_FORBIDDEN), + ("staff", status.HTTP_200_OK), + ) + @ddt.unpack + def test_update_taxonomy(self, user_attr, expected_status): + taxonomy = Taxonomy.objects.create( + name="test update taxonomy", + description="taxonomy description", + enabled=False, + ) + taxonomy.save() + + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": taxonomy.id}) + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + response = self.client.put(url, {"name": "new name"}) + self.assertEqual(response.status_code, expected_status) + + # If we were able to update the taxonomy, check if the name changed + if status.is_success(expected_status): + response = self.client.get(url) + self.assertEqual(response.data["name"], "new name") + self.assertEqual(response.data["enabled"], False) + self.assertEqual(response.data["description"], "taxonomy description") + + def test_update_taxonomy_system_defined(self): + taxonomy = Taxonomy.objects.create( + name="test system taxonomy", system_defined=True + ) + taxonomy.save() + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": taxonomy.id}) + + self.client.force_authenticate(user=self.staff) + response = self.client.put(url, {"name": "new name"}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_taxonomy_404(self): + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": 123123}) + + self.client.force_authenticate(user=self.staff) + response = self.client.put(url, {"name": "new name"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data( + (None, status.HTTP_403_FORBIDDEN), + ("user", status.HTTP_403_FORBIDDEN), + ("staff", status.HTTP_200_OK), + ) + @ddt.unpack + def test_patch_taxonomy(self, user_attr, expected_status): + taxonomy = Taxonomy.objects.create(name="test patch taxonomy", enabled=False) + taxonomy.save() + + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": taxonomy.id}) + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + response = self.client.patch(url, {"name": "new name"}) + self.assertEqual(response.status_code, expected_status) + + # If we were able to update the taxonomy, check if the name changed + if status.is_success(expected_status): + response = self.client.get(url) + self.assertEqual(response.data["name"], "new name") + self.assertEqual(response.data["enabled"], False) + + def test_patch_taxonomy_404(self): + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": 123123}) + + self.client.force_authenticate(user=self.staff) + response = self.client.patch(url, {"name": "new name"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data( + (None, status.HTTP_403_FORBIDDEN), + ("user", status.HTTP_403_FORBIDDEN), + ("staff", status.HTTP_204_NO_CONTENT), + ) + @ddt.unpack + def test_delete_taxonomy(self, user_attr, expected_status): + taxonomy = Taxonomy.objects.create(name="test delete taxonomy") + taxonomy.save() + + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": taxonomy.id}) + + user = getattr(self, user_attr) if user_attr else None + if user: + self.client.force_authenticate(user=user) + response = self.client.delete(url) + self.assertEqual(response.status_code, expected_status) + + # If we were able to delete the taxonomy, check that it's really gone + if status.is_success(expected_status): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_taxonomy_404(self): + url = reverse("oel_tagging:taxonomy-detail", kwargs={"id": 123123}) + + self.client.force_authenticate(user=self.staff) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/tox.ini b/tox.ini index ad8e2cb5..bffe10ff 100644 --- a/tox.ini +++ b/tox.ini @@ -31,7 +31,7 @@ match-dir = (?!migrations) [pytest] DJANGO_SETTINGS_MODULE = test_settings -addopts = --cov openedx_learning --cov openedx_tagging --cov-report term-missing --cov-report xml +addopts = --cov openedx_learning --cov openedx_tagging --cov tests --cov-report term-missing --cov-report xml norecursedirs = .* docs requirements site-packages [testenv]