api.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import logging
  2. from django.contrib.contenttypes.fields import GenericForeignKey
  3. from django.core.exceptions import (
  4. FieldDoesNotExist,
  5. FieldError,
  6. MultipleObjectsReturned,
  7. ObjectDoesNotExist,
  8. ValidationError,
  9. )
  10. from django.db.models.fields.related import ManyToOneRel, RelatedField
  11. from django.urls import reverse
  12. from django.utils.module_loading import import_string
  13. from django.utils.translation import gettext_lazy as _
  14. from rest_framework.permissions import BasePermission
  15. from rest_framework.serializers import ListSerializer, Serializer
  16. from rest_framework.views import get_view_name as drf_get_view_name
  17. from extras.constants import HTTP_CONTENT_TYPE_JSON
  18. from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
  19. from netbox.api.fields import RelatedObjectCountField
  20. from netbox.registry import registry
  21. from .query import count_related, dict_to_filter_params
  22. from .string import title
  23. logger = logging.getLogger('netbox.utilities.api')
  24. __all__ = (
  25. 'IsSuperuser',
  26. 'get_annotations_for_serializer',
  27. 'get_graphql_type_for_model',
  28. 'get_prefetches_for_serializer',
  29. 'get_related_object_by_attrs',
  30. 'get_serializer_for_model',
  31. 'get_view_name',
  32. 'is_api_request',
  33. 'is_graphql_request',
  34. )
  35. class IsSuperuser(BasePermission):
  36. """
  37. Allows access only to superusers.
  38. """
  39. def has_permission(self, request, view):
  40. return bool(request.user and request.user.is_active and request.user.is_superuser)
  41. def get_serializer_for_model(model, prefix=''):
  42. """
  43. Return the appropriate REST API serializer for the given model.
  44. A plugin (or internal app) may register a custom resolver for its own
  45. app via netbox.plugins.register_serializer_resolver() to handle
  46. dynamically generated models or to override serializer resolution. If
  47. a resolver is registered for the model's app and returns a Serializer
  48. subclass, that result is used. Otherwise, the default import-path
  49. lookup runs.
  50. """
  51. app_label, model_name = model._meta.label.split('.')
  52. if resolver := registry['serializer_resolvers'].get(app_label):
  53. try:
  54. serializer = resolver(model, prefix=prefix)
  55. except Exception:
  56. # A buggy resolver must not break serializer lookup for the rest of NetBox.
  57. logger.exception("Serializer resolver %r raised an exception; falling through to default lookup.", resolver)
  58. serializer = None
  59. if serializer is not None:
  60. if isinstance(serializer, type) and issubclass(serializer, Serializer):
  61. return serializer
  62. logger.warning(
  63. "Serializer resolver %r returned %r, which is not a Serializer subclass; "
  64. "falling through to default lookup.",
  65. resolver, serializer,
  66. )
  67. serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
  68. try:
  69. return import_string(serializer_name)
  70. except ImportError:
  71. raise SerializerNotFound(
  72. f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'"
  73. )
  74. def get_graphql_type_for_model(model):
  75. """
  76. Return the GraphQL type class for the given model.
  77. """
  78. app_label, model_name = model._meta.label.split('.')
  79. class_name = f'{app_label}.graphql.types.{model_name}Type'
  80. try:
  81. return import_string(class_name)
  82. except ImportError:
  83. raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_label}.{model_name}")
  84. def is_api_request(request):
  85. """
  86. Return True of the request is being made via the REST API.
  87. """
  88. return request.path_info.startswith(reverse('api-root'))
  89. def is_graphql_request(request):
  90. """
  91. Return True of the request is being made via the GraphQL API.
  92. """
  93. return request.path_info == reverse('graphql') and request.content_type == HTTP_CONTENT_TYPE_JSON
  94. def get_view_name(view):
  95. """
  96. Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name()`.
  97. This function is provided to DRF as its VIEW_NAME_FUNCTION.
  98. """
  99. if hasattr(view, 'queryset') and view.queryset is not None:
  100. # Derive the model name from the queryset.
  101. name = title(view.queryset.model._meta.verbose_name)
  102. if suffix := getattr(view, 'suffix', None):
  103. name = f'{name} {suffix}'
  104. return name
  105. # Fall back to DRF's default behavior
  106. return drf_get_view_name(view)
  107. def _get_nested_serializer(serializer_field):
  108. """
  109. Return the nested serializer instance for a declared serializer field.
  110. """
  111. if isinstance(serializer_field, ListSerializer):
  112. serializer_field = serializer_field.child
  113. if isinstance(serializer_field, Serializer) and hasattr(serializer_field, 'nested'):
  114. return serializer_field
  115. return None
  116. def _get_serializer_fields(serializer: Serializer):
  117. """
  118. Return the effective field names for a serializer instance, honoring any
  119. field-level fields=/omit= overrides.
  120. """
  121. fields = getattr(serializer, '_include_fields', None) or serializer.Meta.fields
  122. omit = getattr(serializer, '_omit_fields', []) or []
  123. return [field_name for field_name in fields if field_name not in omit]
  124. def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
  125. """
  126. Compile and return a list of fields which should be prefetched on the queryset for a serializer.
  127. """
  128. if fields is not None and omit is not None:
  129. raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
  130. model = serializer_class.Meta.model
  131. # If fields are not specified, default to all
  132. fields_to_include = fields or serializer_class.Meta.fields
  133. fields_to_omit = omit or []
  134. prefetch_fields = []
  135. for field_name in fields_to_include:
  136. if field_name in fields_to_omit:
  137. continue
  138. serializer_field = serializer_class._declared_fields.get(field_name)
  139. # Determine the name of the model field referenced by the serializer field
  140. model_field_name = field_name
  141. if serializer_field and getattr(serializer_field, 'source', None):
  142. model_field_name = serializer_field.source
  143. # If the serializer field does not map to a discrete model field, skip it.
  144. try:
  145. field = model._meta.get_field(model_field_name)
  146. if isinstance(field, (RelatedField, ManyToOneRel, GenericForeignKey)):
  147. prefetch_fields.append(field.name)
  148. except FieldDoesNotExist:
  149. continue
  150. # If this field is represented by a nested serializer, recurse to resolve
  151. # prefetches for the related object, honoring any field-level fields=/omit=
  152. # constraints set on that serializer field instance.
  153. if nested_serializer := _get_nested_serializer(serializer_field):
  154. subfields = _get_serializer_fields(nested_serializer)
  155. for subfield in get_prefetches_for_serializer(type(nested_serializer), fields=subfields):
  156. prefetch_fields.append(f'{field.name}__{subfield}')
  157. return prefetch_fields
  158. def get_annotations_for_serializer(serializer_class, fields=None, omit=None):
  159. """
  160. Return a mapping of field names to annotations to be applied to the queryset for a serializer.
  161. """
  162. if fields is not None and omit is not None:
  163. raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
  164. model = serializer_class.Meta.model
  165. # If fields are not specified, default to all
  166. fields_to_include = fields or serializer_class.Meta.fields
  167. fields_to_omit = omit or []
  168. annotations = {}
  169. for field_name, field in serializer_class._declared_fields.items():
  170. if field_name in fields_to_omit:
  171. continue
  172. if field_name in fields_to_include and type(field) is RelatedObjectCountField:
  173. related_field = getattr(model, field.relation).field
  174. annotations[field_name] = count_related(related_field.model, related_field.name)
  175. return annotations
  176. def get_related_object_by_attrs(queryset, attrs, user=None):
  177. """
  178. Return an object identified by either a dictionary of attributes or its numeric primary key (ID). This is used
  179. for referencing related objects when creating/updating objects via the REST API.
  180. When a dictionary of attributes is provided, the queryset is first restricted to only those objects on which the
  181. given user has been granted view permission. This prevents an unprivileged user from enumerating objects by their
  182. attributes. Referencing an object directly by its numeric ID is always permitted, regardless of the user's view
  183. permissions.
  184. :param queryset: The base queryset from which to retrieve the related object
  185. :param attrs: A dictionary of attributes or a numeric primary key identifying the related object
  186. :param user: The user making the request (used to enforce view permissions on attribute-based lookups)
  187. """
  188. if attrs is None:
  189. return None
  190. # Dictionary of related object attributes
  191. if isinstance(attrs, dict):
  192. # Restrict the queryset to only those objects the user is permitted to view. This ensures that filtering by
  193. # attributes cannot be used to enumerate objects which the user is not otherwise permitted to see. Referencing
  194. # an object solely by its numeric ID (e.g. {"id": 123}) is equivalent to passing the ID directly, and is
  195. # always permitted regardless of the user's view permissions.
  196. if list(attrs) != ['id'] and user is not None and hasattr(queryset, 'restrict'):
  197. queryset = queryset.restrict(user, 'view')
  198. params = dict_to_filter_params(attrs)
  199. try:
  200. return queryset.get(**params)
  201. except ObjectDoesNotExist:
  202. raise ValidationError(
  203. _("Related object not found using the provided attributes: {params}").format(params=params))
  204. except MultipleObjectsReturned:
  205. raise ValidationError(
  206. _("Multiple objects match the provided attributes: {params}").format(params=params)
  207. )
  208. except FieldError as e:
  209. raise ValidationError(e)
  210. # Integer PK of related object
  211. try:
  212. # Cast as integer in case a PK was mistakenly sent as a string
  213. pk = int(attrs)
  214. except (TypeError, ValueError):
  215. raise ValidationError(
  216. _(
  217. "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
  218. "unrecognized value: {value}"
  219. ).format(value=attrs)
  220. )
  221. # Look up object by PK
  222. try:
  223. return queryset.get(pk=pk)
  224. except ObjectDoesNotExist:
  225. raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))