from __future__ import unicode_literals from rest_framework import serializers from rest_framework.response import Response from rest_framework.settings import api_settings from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.http import Http404 from django.utils import six from django.utils.translation import gettext as _ ALLOWED_OPS = ('add', 'remove', 'replace') class InvalidAction(Exception): pass HANDLED_EXCEPTIONS = ( serializers.ValidationError, ValidationError, InvalidAction, PermissionDenied, Http404, ) class ApiPatch(object): def __init__(self): self._actions = [] def add(self, path, handler): self._actions.append({ 'op': 'add', 'path': path, 'handler': handler, }) def remove(self, path, handler): self._actions.append({ 'op': 'remove', 'path': path, 'handler': handler, }) def replace(self, path, handler): self._actions.append({ 'op': 'replace', 'path': path, 'handler': handler, }) def dispatch(self, request, target): response = {'id': target.pk} try: self.validate_actions(request.data) except HANDLED_EXCEPTIONS as exception: return self.handle_exception(exception) for action in request.data: try: self.dispatch_action(response, request, target, action) except HANDLED_EXCEPTIONS as exception: return self.handle_exception(exception) return Response(response) def dispatch_bulk(self, request, targets): try: self.validate_actions(request.data['ops']) except HANDLED_EXCEPTIONS as exception: return self.handle_exception(exception) result = [] for target in targets: data = {'id': target.pk, 'status': 200, 'patch': {}} for action in request.data['ops']: try: self.dispatch_action(data['patch'], request, target, action) except HANDLED_EXCEPTIONS as exception: data, status = self.get_error_data_status(exception) data.update({'id': target.pk, 'status': status, }) break result.append(data) # sort result items by id then cast id and status to string # so we are compliant with our bulk actions spec result.sort(key=lambda item: item['id']) for data in result: data['id'] = str(data['id']) data['status'] = str(data['status']) # always returning 200 on op error saves us logic duplication # in the frontend, were we need to do success handling in both # success and error handles return Response(result) def validate_actions(self, actions): if not isinstance(actions, list): raise InvalidAction(_("PATCH request should be a list of operations.")) reduced_actions = [] for action in actions: self.validate_action(action) reduced_action = self.reduce_action(action) if reduced_action in reduced_actions: raise InvalidAction( _('"%(op)s" op for "%(path)s" path is repeated.') % reduced_action) reduced_actions.append(reduced_action) def validate_action(self, action): if not action.get('op'): raise InvalidAction(_('"op" parameter must be defined.')) if action.get('op') not in ALLOWED_OPS: raise InvalidAction(_('"%s" op is unsupported.') % action.get('op')) if not action.get('path'): raise InvalidAction(_('"%s" op has to specify path.') % action.get('op')) if 'value' not in action: raise InvalidAction(_('"%s" op has to specify value.') % action.get('op')) def reduce_action(self, action): return {'op': action['op'], 'path': action['path']} def dispatch_action(self, data, request, target, action): for handler in self._actions: if action['op'] == handler['op'] and action['path'] == handler['path']: with transaction.atomic(): data.update(handler['handler'](request, target, action['value'])) def handle_exception(self, exception): data, status = self.get_error_data_status(exception) return Response(data, status=status) def get_error_data_status(self, exception): if isinstance(exception, InvalidAction): return {api_settings.NON_FIELD_ERRORS_KEY: [six.text_type(exception)]}, 400 if isinstance(exception, serializers.ValidationError): return {'value': exception.detail}, 400 if isinstance(exception, ValidationError): return {'value': exception.messages}, 400 if isinstance(exception, PermissionDenied): return {'detail': six.text_type(exception)}, 403 if isinstance(exception, Http404): return {'detail': 'NOT FOUND'}, 404