Skip to content

Commit 6e25f2d

Browse files
committed
fix: handle unexpected error and duplicates
1 parent 2eebc7b commit 6e25f2d

6 files changed

Lines changed: 254 additions & 10 deletions

File tree

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.38.3"
9+
__version__ = "0.38.4"
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
# Making really sure that any unexpected exception gets logged
35+
log.error(exc, stack_info=True, exc_info=True)
36+
37+
if settings.DEBUG:
38+
description_with_traceback = f"{exc.__class__.__name__}: {str(exc)}\n\nTraceback:\n{traceback.format_exc()}"
39+
40+
return Response(
41+
{"detail": description_with_traceback},
42+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
43+
)
44+
45+
return Response(
46+
{"detail": "An unexpected error occurred while processing the request."},
47+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
48+
)
49+
50+
51+
class TaggingExceptionHandlerMixin:
52+
"""
53+
Scope custom exception handling to tagging API views only.
54+
"""
55+
56+
def get_exception_handler(self):
57+
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)