2
0

api.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import logging
  2. from collections import OrderedDict
  3. import pytz
  4. from django.conf import settings
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied
  7. from django.db import transaction
  8. from django.db.models import ManyToManyField, ProtectedError
  9. from django.urls import reverse
  10. from rest_framework import serializers
  11. from rest_framework.exceptions import APIException, ValidationError
  12. from rest_framework.permissions import BasePermission
  13. from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
  14. from rest_framework.response import Response
  15. from rest_framework.viewsets import ModelViewSet as _ModelViewSet
  16. from .utils import dict_to_filter_params, dynamic_import
  17. class ServiceUnavailable(APIException):
  18. status_code = 503
  19. default_detail = "Service temporarily unavailable, please try again later."
  20. class SerializerNotFound(Exception):
  21. pass
  22. def get_serializer_for_model(model, prefix=''):
  23. """
  24. Dynamically resolve and return the appropriate serializer for a model.
  25. """
  26. app_name, model_name = model._meta.label.split('.')
  27. serializer_name = '{}.api.serializers.{}{}Serializer'.format(
  28. app_name, prefix, model_name
  29. )
  30. try:
  31. return dynamic_import(serializer_name)
  32. except AttributeError:
  33. raise SerializerNotFound(
  34. "Could not determine serializer for {}.{} with prefix '{}'".format(app_name, model_name, prefix)
  35. )
  36. def is_api_request(request):
  37. """
  38. Return True of the request is being made via the REST API.
  39. """
  40. api_path = reverse('api-root')
  41. return request.path_info.startswith(api_path)
  42. #
  43. # Authentication
  44. #
  45. class IsAuthenticatedOrLoginNotRequired(BasePermission):
  46. """
  47. Returns True if the user is authenticated or LOGIN_REQUIRED is False.
  48. """
  49. def has_permission(self, request, view):
  50. if not settings.LOGIN_REQUIRED:
  51. return True
  52. return request.user.is_authenticated
  53. #
  54. # Fields
  55. #
  56. class ChoiceField(serializers.Field):
  57. """
  58. Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Accepts a single value on write.
  59. :param choices: An iterable of choices in the form (value, key).
  60. :param allow_blank: Allow blank values in addition to the listed choices.
  61. """
  62. def __init__(self, choices, allow_blank=False, **kwargs):
  63. self.choiceset = choices
  64. self.allow_blank = allow_blank
  65. self._choices = dict()
  66. # Unpack grouped choices
  67. for k, v in choices:
  68. if type(v) in [list, tuple]:
  69. for k2, v2 in v:
  70. self._choices[k2] = v2
  71. else:
  72. self._choices[k] = v
  73. super().__init__(**kwargs)
  74. def validate_empty_values(self, data):
  75. # Convert null to an empty string unless allow_null == True
  76. if data is None:
  77. if self.allow_null:
  78. return True, None
  79. else:
  80. data = ''
  81. return super().validate_empty_values(data)
  82. def to_representation(self, obj):
  83. if obj is '':
  84. return None
  85. return OrderedDict([
  86. ('value', obj),
  87. ('label', self._choices[obj])
  88. ])
  89. def to_internal_value(self, data):
  90. if data is '':
  91. if self.allow_blank:
  92. return data
  93. raise ValidationError("This field may not be blank.")
  94. # Provide an explicit error message if the request is trying to write a dict or list
  95. if isinstance(data, (dict, list)):
  96. raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
  97. # Check for string representations of boolean/integer values
  98. if hasattr(data, 'lower'):
  99. if data.lower() == 'true':
  100. data = True
  101. elif data.lower() == 'false':
  102. data = False
  103. else:
  104. try:
  105. data = int(data)
  106. except ValueError:
  107. pass
  108. try:
  109. if data in self._choices:
  110. return data
  111. except TypeError: # Input is an unhashable type
  112. pass
  113. raise ValidationError(f"{data} is not a valid choice.")
  114. @property
  115. def choices(self):
  116. return self._choices
  117. class ContentTypeField(RelatedField):
  118. """
  119. Represent a ContentType as '<app_label>.<model>'
  120. """
  121. default_error_messages = {
  122. "does_not_exist": "Invalid content type: {content_type}",
  123. "invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
  124. }
  125. def to_internal_value(self, data):
  126. try:
  127. app_label, model = data.split('.')
  128. return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
  129. except ObjectDoesNotExist:
  130. self.fail('does_not_exist', content_type=data)
  131. except (TypeError, ValueError):
  132. self.fail('invalid')
  133. def to_representation(self, obj):
  134. return "{}.{}".format(obj.app_label, obj.model)
  135. class TimeZoneField(serializers.Field):
  136. """
  137. Represent a pytz time zone.
  138. """
  139. def to_representation(self, obj):
  140. return obj.zone if obj else None
  141. def to_internal_value(self, data):
  142. if not data:
  143. return ""
  144. if data not in pytz.common_timezones:
  145. raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
  146. return pytz.timezone(data)
  147. class SerializedPKRelatedField(PrimaryKeyRelatedField):
  148. """
  149. Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
  150. objects in a ManyToManyField while still allowing a set of primary keys to be written.
  151. """
  152. def __init__(self, serializer, **kwargs):
  153. self.serializer = serializer
  154. self.pk_field = kwargs.pop('pk_field', None)
  155. super().__init__(**kwargs)
  156. def to_representation(self, value):
  157. return self.serializer(value, context={'request': self.context['request']}).data
  158. #
  159. # Serializers
  160. #
  161. # TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant
  162. # way to enforce model validation on the serializer.
  163. class ValidatedModelSerializer(serializers.ModelSerializer):
  164. """
  165. Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation.
  166. """
  167. def validate(self, data):
  168. # Remove custom fields data and tags (if any) prior to model validation
  169. attrs = data.copy()
  170. attrs.pop('custom_fields', None)
  171. attrs.pop('tags', None)
  172. # Skip ManyToManyFields
  173. for field in self.Meta.model._meta.get_fields():
  174. if isinstance(field, ManyToManyField):
  175. attrs.pop(field.name, None)
  176. # Run clean() on an instance of the model
  177. if self.instance is None:
  178. instance = self.Meta.model(**attrs)
  179. else:
  180. instance = self.instance
  181. for k, v in attrs.items():
  182. setattr(instance, k, v)
  183. instance.clean()
  184. instance.validate_unique()
  185. return data
  186. class WritableNestedSerializer(serializers.ModelSerializer):
  187. """
  188. Returns a nested representation of an object on read, but accepts only a primary key on write.
  189. """
  190. def to_internal_value(self, data):
  191. if data is None:
  192. return None
  193. # Dictionary of related object attributes
  194. if isinstance(data, dict):
  195. params = dict_to_filter_params(data)
  196. queryset = self.Meta.model.objects
  197. if hasattr(queryset, 'restrict'):
  198. queryset = queryset.unrestricted()
  199. try:
  200. return queryset.get(**params)
  201. except ObjectDoesNotExist:
  202. raise ValidationError(
  203. "Related object not found using the provided attributes: {}".format(params)
  204. )
  205. except MultipleObjectsReturned:
  206. raise ValidationError(
  207. "Multiple objects match the provided attributes: {}".format(params)
  208. )
  209. except FieldError as e:
  210. raise ValidationError(e)
  211. # Integer PK of related object
  212. if isinstance(data, int):
  213. pk = data
  214. else:
  215. try:
  216. # PK might have been mistakenly passed as a string
  217. pk = int(data)
  218. except (TypeError, ValueError):
  219. raise ValidationError(
  220. "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
  221. "unrecognized value: {}".format(data)
  222. )
  223. # Look up object by PK
  224. queryset = self.Meta.model.objects
  225. if hasattr(queryset, 'restrict'):
  226. queryset = queryset.unrestricted()
  227. try:
  228. return queryset.get(pk=int(data))
  229. except ObjectDoesNotExist:
  230. raise ValidationError(
  231. "Related object not found using the provided numeric ID: {}".format(pk)
  232. )
  233. #
  234. # Viewsets
  235. #
  236. class ModelViewSet(_ModelViewSet):
  237. """
  238. Accept either a single object or a list of objects to create.
  239. """
  240. def get_serializer(self, *args, **kwargs):
  241. # If a list of objects has been provided, initialize the serializer with many=True
  242. if isinstance(kwargs.get('data', {}), list):
  243. kwargs['many'] = True
  244. return super().get_serializer(*args, **kwargs)
  245. def get_serializer_class(self):
  246. logger = logging.getLogger('netbox.api.views.ModelViewSet')
  247. # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
  248. # exists
  249. request = self.get_serializer_context()['request']
  250. if request.query_params.get('brief'):
  251. logger.debug("Request is for 'brief' format; initializing nested serializer")
  252. try:
  253. serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
  254. logger.debug(f"Using serializer {serializer}")
  255. return serializer
  256. except SerializerNotFound:
  257. pass
  258. # Fall back to the hard-coded serializer class
  259. logger.debug(f"Using serializer {self.serializer_class}")
  260. return self.serializer_class
  261. def initial(self, request, *args, **kwargs):
  262. super().initial(request, *args, **kwargs)
  263. if not request.user.is_authenticated:
  264. return
  265. # TODO: Reconcile this with TokenPermissions.perms_map
  266. action = {
  267. 'GET': 'view',
  268. 'OPTIONS': None,
  269. 'HEAD': 'view',
  270. 'POST': 'add',
  271. 'PUT': 'change',
  272. 'PATCH': 'change',
  273. 'DELETE': 'delete',
  274. }[request.method]
  275. # Restrict the view's QuerySet to allow only the permitted objects
  276. if action:
  277. self.queryset = self.queryset.restrict(request.user, action)
  278. def dispatch(self, request, *args, **kwargs):
  279. logger = logging.getLogger('netbox.api.views.ModelViewSet')
  280. try:
  281. return super().dispatch(request, *args, **kwargs)
  282. except ProtectedError as e:
  283. protected_objects = list(e.protected_objects)
  284. msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
  285. msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
  286. logger.warning(msg)
  287. return self.finalize_response(
  288. request,
  289. Response({'detail': msg}, status=409),
  290. *args,
  291. **kwargs
  292. )
  293. def _validate_objects(self, instance):
  294. """
  295. Check that the provided instance or list of instances are matched by the current queryset. This confirms that
  296. any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
  297. """
  298. if type(instance) is list:
  299. # Check that all instances are still included in the view's queryset
  300. conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
  301. if conforming_count != len(instance):
  302. raise ObjectDoesNotExist
  303. else:
  304. # Check that the instance is matched by the view's queryset
  305. self.queryset.get(pk=instance.pk)
  306. def perform_create(self, serializer):
  307. model = self.queryset.model
  308. logger = logging.getLogger('netbox.api.views.ModelViewSet')
  309. logger.info(f"Creating new {model._meta.verbose_name}")
  310. # Enforce object-level permissions on save()
  311. try:
  312. with transaction.atomic():
  313. instance = serializer.save()
  314. self._validate_objects(instance)
  315. except ObjectDoesNotExist:
  316. raise PermissionDenied()
  317. def perform_update(self, serializer):
  318. model = self.queryset.model
  319. logger = logging.getLogger('netbox.api.views.ModelViewSet')
  320. logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
  321. # Enforce object-level permissions on save()
  322. try:
  323. with transaction.atomic():
  324. instance = serializer.save()
  325. self._validate_objects(instance)
  326. except ObjectDoesNotExist:
  327. raise PermissionDenied()
  328. def perform_destroy(self, instance):
  329. model = self.queryset.model
  330. logger = logging.getLogger('netbox.api.views.ModelViewSet')
  331. logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
  332. return super().perform_destroy(instance)