Coverage for utilities/api.py: 80%

141 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import logging 

2 

3from django.contrib.contenttypes.fields import GenericForeignKey 

4from django.core.exceptions import ( 

5 FieldDoesNotExist, 

6 FieldError, 

7 MultipleObjectsReturned, 

8 ObjectDoesNotExist, 

9 ValidationError, 

10) 

11from django.db.models.fields.related import ManyToManyRel, ManyToOneRel, RelatedField 

12from django.urls import reverse 

13from django.utils.module_loading import import_string 

14from django.utils.translation import gettext_lazy as _ 

15from rest_framework.permissions import BasePermission 

16from rest_framework.relations import ManyRelatedField 

17from rest_framework.serializers import ListSerializer, Serializer 

18from rest_framework.views import get_view_name as drf_get_view_name 

19 

20from extras.constants import HTTP_CONTENT_TYPE_JSON 

21from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound 

22from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField 

23from netbox.registry import registry 

24 

25from .query import count_related, dict_to_filter_params 

26from .string import title 

27 

28logger = logging.getLogger('netbox.utilities.api') 

29 

30__all__ = ( 

31 'IsSuperuser', 

32 'get_annotations_for_serializer', 

33 'get_graphql_type_for_model', 

34 'get_positional_errors', 

35 'get_prefetches_for_serializer', 

36 'get_related_object_by_attrs', 

37 'get_serializer_for_model', 

38 'get_view_name', 

39 'is_api_request', 

40 'is_graphql_request', 

41) 

42 

43 

44class IsSuperuser(BasePermission): 

45 """ 

46 Allows access only to superusers. 

47 """ 

48 def has_permission(self, request, view): 

49 return bool(request.user and request.user.is_active and request.user.is_superuser) 

50 

51 

52def get_serializer_for_model(model, prefix=''): 

53 """ 

54 Return the appropriate REST API serializer for the given model. 

55 

56 A plugin (or internal app) may register a custom resolver for its own 

57 app via netbox.plugins.register_serializer_resolver() to handle 

58 dynamically generated models or to override serializer resolution. If 

59 a resolver is registered for the model's app and returns a Serializer 

60 subclass, that result is used. Otherwise, the default import-path 

61 lookup runs. 

62 """ 

63 app_label, model_name = model._meta.label.split('.') 

64 

65 if resolver := registry['serializer_resolvers'].get(app_label): 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 try: 

67 serializer = resolver(model, prefix=prefix) 

68 except Exception: 

69 # A buggy resolver must not break serializer lookup for the rest of NetBox. 

70 logger.exception("Serializer resolver %r raised an exception; falling through to default lookup.", resolver) 

71 serializer = None 

72 if serializer is not None: 

73 if isinstance(serializer, type) and issubclass(serializer, Serializer): 

74 return serializer 

75 logger.warning( 

76 "Serializer resolver %r returned %r, which is not a Serializer subclass; " 

77 "falling through to default lookup.", 

78 resolver, serializer, 

79 ) 

80 

81 serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer' 

82 try: 

83 return import_string(serializer_name) 

84 except ImportError: 

85 raise SerializerNotFound( 

86 f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'" 

87 ) 

88 

89 

90def get_graphql_type_for_model(model): 

91 """ 

92 Return the GraphQL type class for the given model. 

93 """ 

94 app_label, model_name = model._meta.label.split('.') 

95 class_name = f'{app_label}.graphql.types.{model_name}Type' 

96 try: 

97 return import_string(class_name) 

98 except ImportError: 

99 raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_label}.{model_name}") 

100 

101 

102def is_api_request(request): 

103 """ 

104 Return True of the request is being made via the REST API. 

105 """ 

106 return request.path_info.startswith(reverse('api-root')) 

107 

108 

109def is_graphql_request(request): 

110 """ 

111 Return True of the request is being made via the GraphQL API. 

112 """ 

113 return request.path_info == reverse('graphql') and request.content_type == HTTP_CONTENT_TYPE_JSON 

114 

115 

116def get_view_name(view): 

117 """ 

118 Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name()`. 

119 This function is provided to DRF as its VIEW_NAME_FUNCTION. 

120 """ 

121 if hasattr(view, 'queryset') and view.queryset is not None: 

122 # Derive the model name from the queryset. 

123 name = title(view.queryset.model._meta.verbose_name) 

124 if suffix := getattr(view, 'suffix', None): 

125 name = f'{name} {suffix}' 

126 return name 

127 

128 # Fall back to DRF's default behavior 

129 return drf_get_view_name(view) 

130 

131 

132def get_positional_errors(errors, count): 

133 """ 

134 Return the errors reported by a serializer bound to a list of `count` entries as a list 

135 correlated to the positions of those entries, with an empty dict standing in for each entry 

136 which validated. 

137 

138 DRF 3.18 reports the errors of a ListSerializer as a mapping of the index of each failed entry 

139 to that entry's errors, omitting the entries which passed; earlier releases reported a list 

140 aligned with the request body. Restoring the positional form keeps the response shape stable for 

141 API consumers which index into it. 

142 

143 Errors which pertain to the list as a whole rather than to any one entry (e.g. a body which is 

144 not a list at all) carry no position, and are returned unchanged. 

145 

146 :param errors: The `errors` of a serializer instantiated with many=True. 

147 :param count: The number of entries the serializer was bound to. 

148 """ 

149 if not isinstance(errors, dict) or not any(isinstance(index, int) for index in errors): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 return errors 

151 

152 return [errors.get(index, {}) for index in range(count)] 

153 

154 

155def _get_nested_serializer(serializer_field): 

156 """ 

157 Return the nested serializer instance for a declared serializer field. 

158 """ 

159 if isinstance(serializer_field, ListSerializer): 

160 serializer_field = serializer_field.child 

161 

162 # DRF wraps a many-valued related field, keeping the original field on child_relation 

163 if isinstance(serializer_field, ManyRelatedField): 

164 serializer_field = serializer_field.child_relation 

165 

166 if isinstance(serializer_field, SerializedPKRelatedField): 

167 return serializer_field.serializer(nested=serializer_field.nested) 

168 

169 if isinstance(serializer_field, Serializer) and hasattr(serializer_field, 'nested'): 

170 return serializer_field 

171 

172 return None 

173 

174 

175def _get_serializer_fields(serializer: Serializer): 

176 """ 

177 Return the effective field names for a serializer instance, honoring any 

178 field-level fields=/omit= overrides. 

179 """ 

180 fields = getattr(serializer, '_include_fields', None) or serializer.Meta.fields 

181 omit = getattr(serializer, '_omit_fields', []) or [] 

182 

183 return [field_name for field_name in fields if field_name not in omit] 

184 

185 

186def get_prefetches_for_serializer(serializer_class, fields=None, omit=None, _serializer_states=None): 

187 """ 

188 Compile and return a list of fields which should be prefetched on the queryset for a serializer. 

189 """ 

190 if fields is not None and omit is not None: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 raise TypeError("Cannot specify both 'fields' and 'omit' parameters.") 

192 

193 model = serializer_class.Meta.model 

194 

195 # If fields are not specified, default to all 

196 fields_to_include = fields or serializer_class.Meta.fields 

197 fields_to_omit = omit or [] 

198 effective_fields = tuple(name for name in fields_to_include if name not in fields_to_omit) 

199 

200 # Break reference cycles on the current path. The field set is in the key because re-entry at a 

201 # narrower depth is finite, and the states are copied per frame to keep sibling fields independent. 

202 serializer_states = set(_serializer_states or ()) 

203 serializer_state = (serializer_class, effective_fields) 

204 if serializer_state in serializer_states: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 return [] 

206 serializer_states.add(serializer_state) 

207 

208 prefetch_fields = [] 

209 for field_name in effective_fields: 

210 serializer_field = serializer_class._declared_fields.get(field_name) 

211 

212 # Determine the name of the model field referenced by the serializer field 

213 model_field_name = field_name 

214 if serializer_field and getattr(serializer_field, 'source', None): 

215 model_field_name = serializer_field.source 

216 

217 # If the serializer field does not map to a discrete model field, skip it. 

218 try: 

219 field = model._meta.get_field(model_field_name) 

220 if isinstance(field, (RelatedField, ManyToOneRel, ManyToManyRel, GenericForeignKey)): 

221 prefetch_fields.append(field.name) 

222 except FieldDoesNotExist: 

223 continue 

224 

225 # If this field is represented by a nested serializer, recurse to resolve 

226 # prefetches for the related object, honoring any field-level fields=/omit= 

227 # constraints set on that serializer field instance. 

228 if nested_serializer := _get_nested_serializer(serializer_field): 

229 subfields = _get_serializer_fields(nested_serializer) 

230 for subfield in get_prefetches_for_serializer( 

231 type(nested_serializer), fields=subfields, _serializer_states=serializer_states 

232 ): 

233 prefetch_fields.append(f'{field.name}__{subfield}') 

234 

235 return prefetch_fields 

236 

237 

238def get_annotations_for_serializer(serializer_class, fields=None, omit=None): 

239 """ 

240 Return a mapping of field names to annotations to be applied to the queryset for a serializer. 

241 """ 

242 if fields is not None and omit is not None: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

243 raise TypeError("Cannot specify both 'fields' and 'omit' parameters.") 

244 

245 model = serializer_class.Meta.model 

246 

247 # If fields are not specified, default to all 

248 fields_to_include = fields or serializer_class.Meta.fields 

249 fields_to_omit = omit or [] 

250 

251 annotations = {} 

252 for field_name, field in serializer_class._declared_fields.items(): 

253 if field_name in fields_to_omit: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 continue 

255 if field_name in fields_to_include and type(field) is RelatedObjectCountField: 

256 related_field = getattr(model, field.relation).field 

257 annotations[field_name] = count_related(related_field.model, related_field.name) 

258 

259 return annotations 

260 

261 

262def get_related_object_by_attrs(queryset, attrs, user=None): 

263 """ 

264 Return an object identified by either a dictionary of attributes or its numeric primary key (ID). This is used 

265 for referencing related objects when creating/updating objects via the REST API. 

266 

267 When a dictionary of attributes is provided, the queryset is first restricted to only those objects on which the 

268 given user has been granted view permission. This prevents an unprivileged user from enumerating objects by their 

269 attributes. Referencing an object directly by its numeric ID is always permitted, regardless of the user's view 

270 permissions. 

271 

272 :param queryset: The base queryset from which to retrieve the related object 

273 :param attrs: A dictionary of attributes or a numeric primary key identifying the related object 

274 :param user: The user making the request (used to enforce view permissions on attribute-based lookups) 

275 """ 

276 if attrs is None: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 return None 

278 

279 # Dictionary of related object attributes 

280 if isinstance(attrs, dict): 

281 # Restrict the queryset to only those objects the user is permitted to view. This ensures that filtering by 

282 # attributes cannot be used to enumerate objects which the user is not otherwise permitted to see. Referencing 

283 # an object solely by its numeric ID (e.g. {"id": 123}) is equivalent to passing the ID directly, and is 

284 # always permitted regardless of the user's view permissions. 

285 if list(attrs) != ['id'] and user is not None and hasattr(queryset, 'restrict'): 285 ↛ 287line 285 didn't jump to line 287 because the condition on line 285 was always true

286 queryset = queryset.restrict(user, 'view') 

287 params = dict_to_filter_params(attrs) 

288 try: 

289 return queryset.get(**params) 

290 except ObjectDoesNotExist: 

291 raise ValidationError( 

292 _("Related object not found using the provided attributes: {params}").format(params=params)) 

293 except MultipleObjectsReturned: 

294 raise ValidationError( 

295 _("Multiple objects match the provided attributes: {params}").format(params=params) 

296 ) 

297 except FieldError as e: 

298 raise ValidationError(e) 

299 

300 # Integer PK of related object 

301 try: 

302 # Cast as integer in case a PK was mistakenly sent as a string 

303 pk = int(attrs) 

304 except (TypeError, ValueError): 

305 raise ValidationError( 

306 _( 

307 "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " 

308 "unrecognized value: {value}" 

309 ).format(value=attrs) 

310 ) 

311 

312 # Look up object by PK 

313 try: 

314 return queryset.get(pk=pk) 

315 except ObjectDoesNotExist: 

316 raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))