Skip to content

Commit 52bcdcf

Browse files
authored
fix(tagging): Handle rename conflicts; Better 500 handling
This fixes two issues: 1. Renaming a tag such that it conflicts with an existing tag should result in a 4xx and a helpful error message. 2. Unexpected errors while handling requests should result in a simple 500 error response without internal details (unless in debug mode) and a log message with exception details. This is default Django behavior. However DRF returns internal exception details, which is confusing to users and bad security practice. This PR restores the default Django behavior for the Tagging API in particular. In the future, we'd like to generalize this handler to work for all our DRF APIs. Fixes: openedx/modular-learning#261
1 parent 54ef873 commit 52bcdcf

File tree

6 files changed

+255
-10
lines changed

6 files changed

+255
-10
lines changed

src/openedx_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"""
77

88
# The version for the entire repository
9-
__version__ = "0.39.0"
9+
__version__ = "0.39.1"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
Tagging REST API exception handling utilities.
3+
"""
4+
5+
import logging
6+
import traceback
7+
8+
from django.conf import settings
9+
from django.http import Http404
10+
from rest_framework import status
11+
from rest_framework.exceptions import APIException, PermissionDenied
12+
from rest_framework.response import Response
13+
from rest_framework.views import exception_handler
14+
15+
log = logging.getLogger(__name__)
16+
17+
18+
def _custom_exception_handler(exc: Exception, context: dict) -> Response | None:
19+
"""
20+
Return standard DRF errors for APIException and a generic 500 otherwise.
21+
This exception handler should eventually be replaced by a more top-level
22+
exception handler in the openedx-platform repo after the ADR for it is accepted:
23+
https://github.com/openedx/openedx-platform/pull/38246
24+
"""
25+
# For exceptions expected by DRF return the standard DRF error response:
26+
# Instances of APIException, subclasses of APIException, Django's Http404 exception,
27+
# and Django's PermissionDenied exception.
28+
is_expected_exception = isinstance(
29+
exc, (APIException, Http404, PermissionDenied)
30+
)
31+
if is_expected_exception:
32+
return exception_handler(exc, context)
33+
34+
# DRF always calls exception handlers from within an `except:` block, so we can assume
35+
# that `log.exception` will automatically insert those exception details and a stack trace.
36+
log.exception("Unexpected exception while handling API request")
37+
38+
if settings.DEBUG:
39+
description_with_traceback = f"{exc.__class__.__name__}: {str(exc)}\n\nTraceback:\n{traceback.format_exc()}"
40+
41+
return Response(
42+
{"detail": description_with_traceback},
43+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
44+
)
45+
46+
return Response(
47+
{"detail": "An unexpected error occurred while processing the request."},
48+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
49+
)
50+
51+
52+
class TaggingExceptionHandlerMixin:
53+
"""
54+
Scope custom exception handling to tagging API views only.
55+
"""
56+
57+
def get_exception_handler(self):
58+
return _custom_exception_handler

src/openedx_tagging/rest_api/v1/serializers.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from openedx_tagging.data import TagData
1414
from openedx_tagging.import_export.parsers import ParserFormat
1515
from openedx_tagging.models import ObjectTag, Tag, TagImportTask, Taxonomy
16+
from openedx_tagging.models.utils import RESERVED_TAG_CHARS
1617
from openedx_tagging.rules import ObjectTagPermissionItem
1718

1819
from ..utils import UserPermissionsHelper
@@ -59,7 +60,7 @@ def _model(self) -> Type:
5960
@property
6061
def _request(self) -> Request:
6162
"""
62-
Returns the current request from the serialize context.
63+
Returns the current request from the serializer context.
6364
"""
6465
return self.context.get('request') # type: ignore[attr-defined]
6566

@@ -217,6 +218,27 @@ class ObjectTagUpdateBodySerializer(serializers.Serializer): # pylint: disable=
217218
tagsData = serializers.ListField(child=ObjectTagUpdateByTaxonomySerializer(), required=True)
218219

219220

221+
def validate_tag_value(value, context):
222+
"""
223+
Validate this tag value is unique within the current taxonomy context and
224+
does not contain forbidden characters.
225+
"""
226+
taxonomy_id = context.get("taxonomy_id")
227+
if taxonomy_id is not None:
228+
# Check if tag value already exists within this taxonomy. If so, raise a validation error.
229+
queryset = Tag.objects.filter(taxonomy_id=taxonomy_id, value=value)
230+
if queryset.exists():
231+
raise serializers.ValidationError(
232+
f'Tag value "{value}" already exists in this taxonomy.', code='unique'
233+
)
234+
235+
# validator checks there are no forbidden characters ">" or ";":
236+
for char in value:
237+
if char in RESERVED_TAG_CHARS:
238+
raise serializers.ValidationError('Tag values cannot contain "\t" or ">" or ";" characters.')
239+
return value
240+
241+
220242
class TagDataSerializer(UserPermissionsSerializerMixin, serializers.Serializer): # pylint: disable=abstract-method
221243
"""
222244
Serializer for TagData dicts. Also can serialize Tag instances.
@@ -237,6 +259,12 @@ class TagDataSerializer(UserPermissionsSerializerMixin, serializers.Serializer):
237259
can_change_tag = serializers.SerializerMethodField()
238260
can_delete_tag = serializers.SerializerMethodField()
239261

262+
def validate_value(self, value):
263+
"""
264+
Runs validations for the tag value.
265+
"""
266+
return validate_tag_value(value, self.context)
267+
240268
def get_sub_tags_url(self, obj: TagData | Tag):
241269
"""
242270
Returns URL for the list of child tags of the current tag.
@@ -303,6 +331,12 @@ class TaxonomyTagCreateBodySerializer(serializers.Serializer): # pylint: disabl
303331
parent_tag_value = serializers.CharField(required=False)
304332
external_id = serializers.CharField(required=False)
305333

334+
def validate_tag(self, value):
335+
"""
336+
Run validations for the tag value.
337+
"""
338+
return validate_tag_value(value, self.context)
339+
306340

307341
class TaxonomyTagUpdateBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
308342
"""
@@ -312,6 +346,12 @@ class TaxonomyTagUpdateBodySerializer(serializers.Serializer): # pylint: disabl
312346
tag = serializers.CharField(required=True)
313347
updated_tag_value = serializers.CharField(required=True)
314348

349+
def validate_updated_tag_value(self, value):
350+
"""
351+
Run validations for the updated tag value.
352+
"""
353+
return validate_tag_value(value, self.context)
354+
315355

316356
class TaxonomyTagDeleteBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
317357
"""
@@ -323,6 +363,25 @@ class TaxonomyTagDeleteBodySerializer(serializers.Serializer): # pylint: disabl
323363
)
324364
with_subtags = serializers.BooleanField(required=False)
325365

366+
def validate_tags(self, tags_list):
367+
"""
368+
Make sure all tags are valid and exist before attempting deletion, to avoid partial deletes.
369+
"""
370+
# Iterate through the list and make one bulk request that checks whether every tag.value exists
371+
taxonomy_id = self.context.get("taxonomy_id")
372+
existing_tags = set(
373+
Tag.objects.filter(taxonomy_id=taxonomy_id, value__in=tags_list)
374+
.values_list("value", flat=True)
375+
)
376+
missing_tags = set(tags_list) - existing_tags
377+
378+
if missing_tags:
379+
raise serializers.ValidationError(
380+
f"Deletion aborted. The following tags do not exist and cannot be deleted:"
381+
f" {', '.join(missing_tags)}"
382+
)
383+
return tags_list
384+
326385

327386
class TaxonomyImportBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
328387
"""

src/openedx_tagging/rest_api/v1/views.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ...rules import ObjectTagPermissionItem
3535
from ..paginators import MAX_FULL_DEPTH_THRESHOLD, DisabledTagsPagination, TagsPagination, TaxonomyPagination
3636
from ..utils import view_auth_classes
37+
from .exception_handlers import TaggingExceptionHandlerMixin
3738
from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions, TaxonomyTagsObjectPermissions
3839
from .serializers import (
3940
ObjectTagListQueryParamsSerializer,
@@ -55,7 +56,7 @@
5556

5657

5758
@view_auth_classes
58-
class TaxonomyView(ModelViewSet):
59+
class TaxonomyView(TaggingExceptionHandlerMixin, ModelViewSet):
5960
"""
6061
View to list, create, retrieve, update, delete, export or import Taxonomies.
6162
@@ -640,7 +641,7 @@ def retrieve(self, request, *args, **kwargs) -> Response:
640641

641642

642643
@view_auth_classes
643-
class TaxonomyTagsView(ListAPIView, RetrieveUpdateDestroyAPIView):
644+
class TaxonomyTagsView(TaggingExceptionHandlerMixin, ListAPIView, RetrieveUpdateDestroyAPIView):
644645
"""
645646
View to list/create/update/delete tags of a taxonomy.
646647
@@ -865,7 +866,8 @@ def post(self, request, *args, **kwargs):
865866
"""
866867
taxonomy = self.get_taxonomy()
867868

868-
body = TaxonomyTagCreateBodySerializer(data=request.data)
869+
serializer_context = self.get_serializer_context()
870+
body = TaxonomyTagCreateBodySerializer(data=request.data, context=serializer_context)
869871
body.is_valid(raise_exception=True)
870872

871873
tag = body.data.get("tag")
@@ -881,7 +883,6 @@ def post(self, request, *args, **kwargs):
881883
except ValueError as e:
882884
raise ValidationError(e) from e
883885

884-
serializer_context = self.get_serializer_context()
885886
return Response(
886887
self.serializer_class(new_tag, context=serializer_context).data,
887888
status=status.HTTP_201_CREATED
@@ -894,7 +895,8 @@ def update(self, request, *args, **kwargs):
894895
"""
895896
taxonomy = self.get_taxonomy()
896897

897-
body = TaxonomyTagUpdateBodySerializer(data=request.data)
898+
serializer_context = self.get_serializer_context()
899+
body = TaxonomyTagUpdateBodySerializer(data=request.data, context=serializer_context)
898900
body.is_valid(raise_exception=True)
899901

900902
tag = body.data.get("tag")
@@ -907,7 +909,6 @@ def update(self, request, *args, **kwargs):
907909
except ValueError as e:
908910
raise ValidationError(e) from e
909911

910-
serializer_context = self.get_serializer_context()
911912
return Response(
912913
self.serializer_class(updated_tag, context=serializer_context).data,
913914
status=status.HTTP_200_OK
@@ -921,7 +922,8 @@ def delete(self, request, *args, **kwargs):
921922
"""
922923
taxonomy = self.get_taxonomy()
923924

924-
body = TaxonomyTagDeleteBodySerializer(data=request.data)
925+
serializer_context = self.get_serializer_context()
926+
body = TaxonomyTagDeleteBodySerializer(data=request.data, context=serializer_context)
925927
body.is_valid(raise_exception=True)
926928

927929
tags = body.data.get("tags")

src/openedx_tagging/rest_api/v1/views_import.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from rest_framework.request import Request
1010
from rest_framework.views import APIView
1111

12+
from .exception_handlers import TaggingExceptionHandlerMixin
1213

13-
class TemplateView(APIView):
14+
15+
class TemplateView(TaggingExceptionHandlerMixin, APIView):
1416
"""
1517
View which serves the static Taxonomy Import template files.
1618

0 commit comments

Comments
 (0)