patch.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. from __future__ import unicode_literals
  2. from rest_framework.exceptions import ValidationError as ApiValidationError
  3. from rest_framework.response import Response
  4. from django.core.exceptions import PermissionDenied, ValidationError
  5. from django.db import transaction
  6. from django.http import Http404
  7. from django.utils import six
  8. from django.utils.translation import gettext as _
  9. ALLOWED_OPS = ('add', 'remove', 'replace')
  10. class InvalidAction(Exception):
  11. pass
  12. HANDLED_EXCEPTIONS = (
  13. ApiValidationError,
  14. ValidationError,
  15. InvalidAction,
  16. PermissionDenied,
  17. Http404,
  18. )
  19. class ApiPatch(object):
  20. def __init__(self):
  21. self._actions = []
  22. def add(self, path, handler):
  23. self._actions.append({
  24. 'op': 'add',
  25. 'path': path,
  26. 'handler': handler,
  27. })
  28. def remove(self, path, handler):
  29. self._actions.append({
  30. 'op': 'remove',
  31. 'path': path,
  32. 'handler': handler,
  33. })
  34. def replace(self, path, handler):
  35. self._actions.append({
  36. 'op': 'replace',
  37. 'path': path,
  38. 'handler': handler,
  39. })
  40. def dispatch(self, request, target):
  41. response = {'id': target.pk}
  42. try:
  43. self.validate_actions(request.data)
  44. for action in request.data:
  45. self.dispatch_action(response, request, target, action)
  46. except HANDLED_EXCEPTIONS as exception:
  47. detail, status = self.get_error_detail_code(exception)
  48. return Response({'detail': detail}, status=status)
  49. return Response(response)
  50. def dispatch_bulk(self, request, targets):
  51. try:
  52. self.validate_actions(request.data['ops'])
  53. except HANDLED_EXCEPTIONS as exception:
  54. return self.handle_exception(exception)
  55. result = []
  56. for target in targets:
  57. data = {'id': target.pk, 'status': 200, 'patch': {}}
  58. for action in request.data['ops']:
  59. try:
  60. self.dispatch_action(data['patch'], request, target, action)
  61. except HANDLED_EXCEPTIONS as exception:
  62. detail, status = self.get_error_detail_code(exception)
  63. data = {
  64. 'id': target.pk,
  65. 'status': status,
  66. 'detail': detail,
  67. }
  68. break
  69. result.append(data)
  70. # sort result items by id then cast id and status to string
  71. # so we are compliant with our bulk actions spec
  72. result.sort(key=lambda item: item['id'])
  73. for data in result:
  74. data['id'] = str(data['id'])
  75. data['status'] = str(data['status'])
  76. # always returning 200 on op error saves us logic duplication
  77. # in the frontend, were we need to do success handling in both
  78. # success and error handles
  79. return Response(result)
  80. def validate_actions(self, actions):
  81. if not isinstance(actions, list):
  82. raise ApiValidationError(_("PATCH request should be a list of operations."))
  83. reduced_actions = []
  84. for action in actions:
  85. self.validate_action(action)
  86. reduced_action = self.reduce_action(action)
  87. if reduced_action in reduced_actions:
  88. raise InvalidAction(
  89. _('"%(op)s" op for "%(path)s" path is repeated.') % reduced_action)
  90. reduced_actions.append(reduced_action)
  91. def validate_action(self, action):
  92. if not action.get('op'):
  93. raise InvalidAction(_('"op" parameter must be defined.'))
  94. if action.get('op') not in ALLOWED_OPS:
  95. raise InvalidAction(_('"%s" op is unsupported.') % action.get('op'))
  96. if not action.get('path'):
  97. raise InvalidAction(_('"%s" op has to specify path.') % action.get('op'))
  98. if 'value' not in action:
  99. raise InvalidAction(_('"%s" op has to specify value.') % action.get('op'))
  100. def reduce_action(self, action):
  101. return {'op': action['op'], 'path': action['path']}
  102. def dispatch_action(self, data, request, target, action):
  103. for handler in self._actions:
  104. if action['op'] == handler['op'] and action['path'] == handler['path']:
  105. with transaction.atomic():
  106. data.update(handler['handler'](request, target, action['value']))
  107. def handle_exception(self, exception):
  108. detail, status = self.get_error_detail_code(exception)
  109. return Response({'detail': detail}, status=status)
  110. def get_error_detail_code(self, exception):
  111. if isinstance(exception, InvalidAction):
  112. return six.text_type(exception), 400
  113. if isinstance(exception, ApiValidationError):
  114. return exception.detail, 400
  115. if isinstance(exception, ValidationError):
  116. return exception.messages, 400
  117. if isinstance(exception, PermissionDenied):
  118. return six.text_type(exception), 403
  119. if isinstance(exception, Http404):
  120. return 'NOT FOUND', 404