Coverage for netbox_data_import/api/views.py: 98%
99 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-09 20:50 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-09 20:50 +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 viewsets, permissions
8from rest_framework.permissions import DjangoModelPermissions
10from ..models import (
11 locked_profile_policy,
12 locked_resolution_policy,
13 ImportProfile,
14 ColumnMapping,
15 ClassRoleMapping,
16 DeviceTypeMapping,
17 IgnoredDevice,
18 ColumnTransformRule,
19 SourceResolution,
20 ImportExecution,
21)
22from .serializers import (
23 ImportProfileSerializer,
24 ColumnMappingSerializer,
25 ClassRoleMappingSerializer,
26 DeviceTypeMappingSerializer,
27 IgnoredDeviceSerializer,
28 ColumnTransformRuleSerializer,
29 SourceResolutionSerializer,
30 ImportExecutionSerializer,
31)
34class DjangoModelPermissionsWithView(DjangoModelPermissions):
35 """Extends DjangoModelPermissions to require view_* permission for GET requests.
37 The stock DjangoModelPermissions does not map GET to any model permission,
38 so list/retrieve endpoints are accessible to any authenticated user. This
39 subclass closes that gap.
40 """
42 perms_map = {
43 **DjangoModelPermissions.perms_map,
44 "GET": ["%(app_label)s.view_%(model_name)s"],
45 "HEAD": ["%(app_label)s.view_%(model_name)s"],
46 "OPTIONS": [],
47 }
50class ImportProfileViewSet(NetBoxModelViewSet):
51 """CRUD viewset for ImportProfile (NetBoxModel)."""
53 queryset = ImportProfile.objects.prefetch_related(
54 "tags", "column_mappings", "class_role_mappings", "device_type_mappings"
55 )
56 serializer_class = ImportProfileSerializer
59class _PluginModelViewSet(viewsets.ModelViewSet):
60 """Base class for plain-model viewsets in this plugin."""
62 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
65class ColumnMappingViewSet(_PluginModelViewSet):
66 """CRUD viewset for ColumnMapping."""
68 queryset = ColumnMapping.objects.select_related("profile")
69 serializer_class = ColumnMappingSerializer
71 def get_queryset(self):
72 """Filter by profile_id query param if provided."""
73 qs = super().get_queryset()
74 profile_id = self.request.query_params.get("profile_id")
75 if profile_id:
76 qs = qs.filter(profile_id=profile_id)
77 return qs
80class ClassRoleMappingViewSet(_PluginModelViewSet):
81 """CRUD viewset for ClassRoleMapping."""
83 queryset = ClassRoleMapping.objects.select_related("profile", "rack_type")
84 serializer_class = ClassRoleMappingSerializer
86 def get_queryset(self):
87 """Filter by profile_id query param if provided."""
88 qs = super().get_queryset()
89 profile_id = self.request.query_params.get("profile_id")
90 if profile_id:
91 qs = qs.filter(profile_id=profile_id)
92 return qs
95class DeviceTypeMappingViewSet(_PluginModelViewSet):
96 """CRUD viewset for DeviceTypeMapping."""
98 queryset = DeviceTypeMapping.objects.select_related("profile")
99 serializer_class = DeviceTypeMappingSerializer
101 def get_queryset(self):
102 """Filter by profile_id query param if provided."""
103 qs = super().get_queryset()
104 profile_id = self.request.query_params.get("profile_id")
105 if profile_id:
106 qs = qs.filter(profile_id=profile_id)
107 return qs
110class IgnoredDeviceViewSet(_PluginModelViewSet):
111 """CRUD viewset for IgnoredDevice."""
113 queryset = IgnoredDevice.objects.select_related("profile")
114 serializer_class = IgnoredDeviceSerializer
116 def get_queryset(self):
117 """Filter by profile_id query param if provided."""
118 qs = super().get_queryset()
119 profile_id = self.request.query_params.get("profile_id")
120 if profile_id:
121 qs = qs.filter(profile_id=profile_id)
122 return qs
125class ColumnTransformRuleViewSet(_PluginModelViewSet):
126 """CRUD viewset for ColumnTransformRule."""
128 queryset = ColumnTransformRule.objects.select_related("profile")
129 serializer_class = ColumnTransformRuleSerializer
131 def get_queryset(self):
132 """Filter by profile_id query param if provided."""
133 qs = super().get_queryset()
134 profile_id = self.request.query_params.get("profile_id")
135 if profile_id:
136 qs = qs.filter(profile_id=profile_id)
137 return qs
140def _revalidate_against_the_stored_row(serializer):
141 """Read the resolution again and check the request against the row as it now stands.
143 save() writes every field, so a request that changed another field first would otherwise be
144 undone. The whole validation runs again rather than validate() alone, because the field checks
145 read the stored row too, and the profile lock makes this reading of it authoritative. The
146 result is discarded: the values are the request's own, which the first pass already holds.
147 """
148 serializer.instance = SourceResolution.objects.get(pk=serializer.instance.pk)
149 serializer.run_validation(serializer.initial_data)
152class SourceResolutionViewSet(_PluginModelViewSet):
153 """CRUD viewset for SourceResolution (rerere)."""
155 queryset = SourceResolution.objects.select_related("profile")
156 serializer_class = SourceResolutionSerializer
158 # Each write serializes against an executing import, which holds the same profile row.
159 def perform_create(self, serializer):
160 """Create the resolution under the profile lock."""
161 try:
162 with locked_profile_policy(serializer.validated_data["profile"].pk):
163 serializer.save()
164 except ImportProfile.DoesNotExist:
165 # The profile is read to validate the request, and can be deleted before the lock.
166 raise Http404 from None
168 def perform_update(self, serializer):
169 """Update the resolution under its profile lock."""
170 # ValidatedModelSerializer.validate() writes the request values onto the instance, so only
171 # its primary key still names the stored row.
172 try:
173 with locked_resolution_policy(serializer.instance.pk):
174 _revalidate_against_the_stored_row(serializer)
175 serializer.save()
176 except (SourceResolution.DoesNotExist, ImportProfile.DoesNotExist):
177 raise Http404 from None
179 def perform_destroy(self, instance):
180 """Delete the resolution under its profile lock."""
181 try:
182 with locked_resolution_policy(instance.pk):
183 instance.delete()
184 except (SourceResolution.DoesNotExist, ImportProfile.DoesNotExist):
185 raise Http404 from None
187 def get_queryset(self):
188 """Filter by profile_id query param if provided."""
189 qs = super().get_queryset()
190 profile_id = self.request.query_params.get("profile_id")
191 if profile_id:
192 qs = qs.filter(profile_id=profile_id)
193 return qs
196class ImportExecutionViewSet(viewsets.ReadOnlyModelViewSet):
197 """Read-only viewset for the Import Execution audit history."""
199 queryset = ImportExecution.objects.select_related("profile")
200 serializer_class = ImportExecutionSerializer
201 permission_classes = [permissions.IsAuthenticated, DjangoModelPermissionsWithView]
203 def get_queryset(self):
204 """Filter by profile_id query param if provided."""
205 qs = super().get_queryset()
206 profile_id = self.request.query_params.get("profile_id")
207 if profile_id:
208 qs = qs.filter(profile_id=profile_id)
209 return qs