Coverage for netbox_data_import/api/views.py: 96.69%
151 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 09:47 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3"""DRF viewsets for the data-import plugin API."""
5from django.http import Http404
6from netbox.api.viewsets import NetBoxModelViewSet
7from rest_framework import mixins, permissions, viewsets
8from rest_framework.exceptions import PermissionDenied, ValidationError
9from rest_framework.permissions import DjangoModelPermissions
11from ..filters import ImportProfileFilterSet, InferenceBackendFilterSet
12from ..field_keys import SELECT_TERMINATION_TASK, parse_termination_field_key
13from ..models import (
14 locked_profile_policy,
15 ImportProfile,
16 CableClassMapping,
17 CableImportSource,
18 ColumnMapping,
19 ClassRoleMapping,
20 DeviceTypeMapping,
21 IgnoredDevice,
22 ColumnTransformRule,
23 SourceResolution,
24 ImportExecution,
25 InferenceBackend,
26 PortClassMapping,
27 ResolutionProposal,
28)
29from ..object_permissions import (
30 ObjectPermissionDenied,
31 delete_permission_scoped_objects,
32 save_permission_scoped_object,
33)
34from .serializers import (
35 ImportProfileSerializer,
36 CableClassMappingSerializer,
37 CableImportSourceSerializer,
38 ColumnMappingSerializer,
39 ClassRoleMappingSerializer,
40 DeviceTypeMappingSerializer,
41 IgnoredDeviceSerializer,
42 ColumnTransformRuleSerializer,
43 SourceResolutionSerializer,
44 ImportExecutionSerializer,
45 InferenceBackendSerializer,
46 PortClassMappingSerializer,
47 ResolutionProposalHistorySerializer,
48 ResolutionProposalSerializer,
49)
52class DjangoModelPermissionsWithView(DjangoModelPermissions):
53 """Extends DjangoModelPermissions to require view_* permission for GET requests.
55 The stock DjangoModelPermissions does not map GET to any model permission,
56 so list/retrieve endpoints are accessible to any authenticated user. This
57 subclass closes that gap.
58 """
60 perms_map = {
61 **DjangoModelPermissions.perms_map,
62 "GET": ["%(app_label)s.view_%(model_name)s"],
63 "HEAD": ["%(app_label)s.view_%(model_name)s"],
64 "OPTIONS": [],
65 }
68class ImportProfileViewSet(NetBoxModelViewSet):
69 """CRUD viewset for ImportProfile (NetBoxModel)."""
71 queryset = ImportProfile.objects.prefetch_related(
72 "tags", "column_mappings", "class_role_mappings", "device_type_mappings"
73 )
74 serializer_class = ImportProfileSerializer
75 filterset_class = ImportProfileFilterSet
78class _ProfileScopedQuerySetMixin(viewsets.GenericViewSet):
79 """Restrict profile-owned rows and validate their optional profile filter."""
81 def get_queryset(self):
82 """Return viewable rows, filtered by a valid profile ID when supplied."""
83 qs = super().get_queryset().restrict(self.request.user, "view")
84 profile_id = self.request.query_params.get("profile_id")
85 if profile_id is not None:
86 try:
87 profile_id = int(profile_id)
88 except (TypeError, ValueError) as exc:
89 raise ValidationError({"profile_id": "Enter a whole number."}) from exc
90 qs = qs.filter(profile_id=profile_id)
91 return qs
94class _PluginModelViewSet(_ProfileScopedQuerySetMixin, viewsets.ModelViewSet):
95 """Base class for plain-model viewsets in this plugin."""
97 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
99 def perform_create(self, serializer):
100 """Create one policy row inside its profile and object-permission scope."""
101 model = serializer.Meta.model
102 profile_id = serializer.validated_data["profile"].pk
103 try:
104 with locked_profile_policy(profile_id):
105 serializer.run_validation(serializer.initial_data)
106 values = serializer.model_cleaned_values()
107 profile = values.pop("profile")
108 result = save_permission_scoped_object(
109 self.request.user,
110 model,
111 {"pk": None, "profile": profile},
112 values,
113 on_existing="reject",
114 )
115 except ImportProfile.DoesNotExist:
116 raise Http404 from None
117 except ObjectPermissionDenied as exc:
118 raise PermissionDenied from exc
119 serializer.instance = result.instance
121 def perform_update(self, serializer):
122 """Update one policy row under every affected profile and object scope."""
123 model = serializer.Meta.model
124 row_pk = serializer.instance.pk
125 stored_profile_id = model.objects.filter(pk=row_pk).values_list("profile_id", flat=True).first()
126 if stored_profile_id is None:
127 raise Http404
128 requested_profile = serializer.validated_data.get("profile")
129 requested_profile_id = requested_profile.pk if requested_profile is not None else stored_profile_id
130 try:
131 with locked_profile_policy(stored_profile_id, requested_profile_id):
132 serializer.instance = model.objects.get(pk=row_pk, profile_id=stored_profile_id)
133 serializer.run_validation(serializer.initial_data)
134 result = save_permission_scoped_object(
135 self.request.user,
136 model,
137 {"pk": row_pk, "profile_id": stored_profile_id},
138 serializer.model_cleaned_values(),
139 )
140 except (model.DoesNotExist, ImportProfile.DoesNotExist):
141 raise Http404 from None
142 except ObjectPermissionDenied as exc:
143 raise PermissionDenied from exc
144 serializer.instance = result.instance
146 def perform_destroy(self, instance):
147 """Delete one policy row under its current profile and object scope."""
148 model = type(instance)
149 profile_id = model.objects.filter(pk=instance.pk).values_list("profile_id", flat=True).first()
150 if profile_id is None:
151 raise Http404
152 try:
153 with locked_profile_policy(profile_id):
154 deleted = delete_permission_scoped_objects(
155 self.request.user,
156 model.objects.filter(pk=instance.pk, profile_id=profile_id),
157 )
158 if deleted != 1:
159 raise Http404
160 except ImportProfile.DoesNotExist:
161 raise Http404 from None
162 except ObjectPermissionDenied as exc:
163 raise PermissionDenied from exc
166class ColumnMappingViewSet(_PluginModelViewSet):
167 """CRUD viewset for ColumnMapping."""
169 queryset = ColumnMapping.objects.select_related("profile")
170 serializer_class = ColumnMappingSerializer
173class CableClassMappingViewSet(_PluginModelViewSet):
174 """CRUD viewset for CableClassMapping."""
176 queryset = CableClassMapping.objects.select_related("profile")
177 serializer_class = CableClassMappingSerializer
180class PortClassMappingViewSet(_PluginModelViewSet):
181 """CRUD viewset for PortClassMapping."""
183 queryset = PortClassMapping.objects.select_related("profile")
184 serializer_class = PortClassMappingSerializer
187class ClassRoleMappingViewSet(_PluginModelViewSet):
188 """CRUD viewset for ClassRoleMapping."""
190 queryset = ClassRoleMapping.objects.select_related("profile", "rack_type")
191 serializer_class = ClassRoleMappingSerializer
194class DeviceTypeMappingViewSet(_PluginModelViewSet):
195 """CRUD viewset for DeviceTypeMapping."""
197 queryset = DeviceTypeMapping.objects.select_related("profile")
198 serializer_class = DeviceTypeMappingSerializer
201class IgnoredDeviceViewSet(_PluginModelViewSet):
202 """CRUD viewset for IgnoredDevice."""
204 queryset = IgnoredDevice.objects.select_related("profile")
205 serializer_class = IgnoredDeviceSerializer
208class ColumnTransformRuleViewSet(_PluginModelViewSet):
209 """CRUD viewset for ColumnTransformRule."""
211 queryset = ColumnTransformRule.objects.select_related("profile")
212 serializer_class = ColumnTransformRuleSerializer
215class SourceResolutionViewSet(_PluginModelViewSet):
216 """CRUD viewset for SourceResolution (rerere)."""
218 queryset = SourceResolution.objects.select_related("profile")
219 serializer_class = SourceResolutionSerializer
222class ImportExecutionViewSet(_ProfileScopedQuerySetMixin, viewsets.ReadOnlyModelViewSet):
223 """Read-only viewset for the Import Execution audit history."""
225 queryset = ImportExecution.objects.select_related("profile")
226 serializer_class = ImportExecutionSerializer
227 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
230class CableImportSourceViewSet(_ProfileScopedQuerySetMixin, viewsets.ReadOnlyModelViewSet):
231 """Read-only viewset for per-Cable import provenance."""
233 queryset = CableImportSource.objects.select_related("cable", "profile")
234 serializer_class = CableImportSourceSerializer
235 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
237 def get_queryset(self):
238 """Apply the profile scope and an optional Cable ID filter."""
239 profile_scope = ImportProfile.objects.restrict(self.request.user, "view")
240 qs = super().get_queryset().filter(profile__in=profile_scope)
241 cable_id = self.request.query_params.get("cable_id")
242 if cable_id is not None:
243 try:
244 cable_id = int(cable_id)
245 except (TypeError, ValueError) as exc:
246 raise ValidationError({"cable_id": "Enter a whole number."}) from exc
247 qs = qs.filter(cable_id=cable_id)
248 return qs
251class ResolutionProposalViewSet(_ProfileScopedQuerySetMixin, viewsets.ReadOnlyModelViewSet):
252 """Read-only viewset for Resolution Proposal history."""
254 queryset = ResolutionProposal.objects.select_related("profile")
255 serializer_class = ResolutionProposalSerializer
256 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
258 def get_queryset(self):
259 """Return proposals only for profiles that the actor can view."""
260 profile_scope = ImportProfile.objects.restrict(self.request.user, "view")
261 return super().get_queryset().filter(profile__in=profile_scope)
264class ResolutionProposalHistoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
265 """Read one field's complete paginated history with workspace permissions."""
267 queryset = ResolutionProposal.objects.none()
268 serializer_class = ResolutionProposalHistorySerializer
269 permission_classes = [permissions.IsAuthenticated]
271 def get_queryset(self):
272 """Return one profile field's history when the actor can view that profile."""
273 profile_values = self.request.query_params.getlist("profile_id")
274 field_values = self.request.query_params.getlist("field_key")
275 if len(profile_values) != 1 or not profile_values[0]:
276 raise ValidationError({"profile_id": "Provide one whole-number profile ID."})
277 if len(field_values) != 1 or not field_values[0]:
278 raise ValidationError({"field_key": "Provide one canonical termination field key."})
279 try:
280 profile_id = int(profile_values[0])
281 except (TypeError, ValueError) as exc:
282 raise ValidationError({"profile_id": "Provide one whole-number profile ID."}) from exc
283 try:
284 parse_termination_field_key(field_values[0])
285 except ValueError as exc:
286 raise ValidationError({"field_key": "Provide one canonical termination field key."}) from exc
287 if not ImportProfile.objects.restrict(self.request.user, "view").filter(pk=profile_id).exists():
288 raise Http404
289 return (
290 ResolutionProposal.objects.filter(
291 profile_id=profile_id,
292 task_type=SELECT_TERMINATION_TASK,
293 field_key=field_values[0],
294 )
295 .select_related("profile")
296 .order_by("-created", "-pk")
297 )
300class InferenceBackendViewSet(NetBoxModelViewSet):
301 """CRUD viewset whose configuration fields derive from the Inference Backend form."""
303 queryset = InferenceBackend.objects.prefetch_related("tags")
304 serializer_class = InferenceBackendSerializer
305 filterset_class = InferenceBackendFilterSet