Coverage for netbox_data_import/api/serializers.py: 96.20%

158 statements  

« 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 serializers for the data-import plugin models.""" 

4 

5import json 

6 

7from django.core.exceptions import ValidationError 

8from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer 

9from rest_framework import serializers 

10 

11from ..adapters import DEFAULT_ADAPTER_KEY, get_adapter, selectable_adapter_choices 

12from ..catalog import CATALOG 

13from ..forms import InferenceBackendForm 

14from ..models import ( 

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 validate_adapter_target_module, 

29 validate_contact_candidate_resolution, 

30 validate_section_applicability, 

31) 

32 

33 

34def _validate_candidate_values(candidate_values): 

35 """Require a JSON object at the Contact candidate boundary.""" 

36 if not isinstance(candidate_values, dict): 

37 raise TypeError("Contact candidate values must be a JSON object.") 

38 

39 

40class PolicySectionApplicabilityMixin: 

41 """Apply the shared policy-section applicability rule on the REST write path.""" 

42 

43 def validate_policy_section(self, attrs): 

44 """Reject a row whose section does not apply to the profile's Source Adapter.""" 

45 profile = attrs.get("profile", getattr(self.instance, "profile", None)) 

46 try: 

47 validate_section_applicability(profile, self.Meta.model.POLICY_SECTION) 

48 except ValidationError as exc: 

49 raise serializers.ValidationError({"profile": exc.messages}) from exc 

50 

51 

52class PolicySectionSerializer(PolicySectionApplicabilityMixin, ValidatedModelSerializer): 

53 """Base for the policy-section serializers, which are backed by plain Django models.""" 

54 

55 # No Meta.fields below lists display_url: these rows have no UI detail route to reverse. 

56 

57 def validate(self, attrs): 

58 """Run the policy checks, then the NetBox model validation.""" 

59 # A nested serializer is handed a resolved instance, so no field mapping exists to check. 

60 if getattr(self, "nested", False): 

61 return super().validate(attrs) 

62 # Ahead of super(), whose full_clean() reports the same rules as non-field errors. 

63 self.validate_policy_section(attrs) 

64 self.validate_policy_row(attrs) 

65 return super().validate(attrs) 

66 

67 def validate_policy_row(self, attrs): 

68 """Check the fields this model resolves through the catalog. Subclasses override.""" 

69 

70 def model_cleaned_values(self): 

71 """Return request values after applying the model's canonical normalization.""" 

72 values = dict(self.validated_data) 

73 if self.instance is None: 

74 instance = self.Meta.model(**values) 

75 instance.full_clean(validate_unique=False) 

76 else: 

77 # ValidatedModelSerializer.validate() applies request values and full_clean() to this instance. 

78 instance = self.instance 

79 return {name: getattr(instance, name) for name in values} 

80 

81 

82def _validate_target_keys(instance, attrs, names, *, allow_candidates=True, required=False): 

83 """Reject a target key the profile's Source Adapter cannot supply.""" 

84 profile = attrs.get("profile", getattr(instance, "profile", None)) 

85 output_kinds = profile.output_kinds if profile is not None else None 

86 for name in names: 

87 value = attrs.get(name, getattr(instance, name, None)) or "" 

88 if not value and not required: 

89 continue 

90 if not CATALOG.is_valid(value, output_kinds=output_kinds, allow_candidates=allow_candidates): 

91 raise serializers.ValidationError({name: CATALOG.invalid_key_message(value)}) 

92 

93 

94class ImportProfileSerializer(NetBoxModelSerializer): 

95 """Full serializer for ImportProfile (NetBoxModel).""" 

96 

97 url = serializers.HyperlinkedIdentityField( 

98 view_name="plugins-api:netbox_data_import-api:importprofile-detail", 

99 ) 

100 

101 class Meta: 

102 model = ImportProfile 

103 brief_fields = ["id", "url", "display", "name", "description"] 

104 fields = [ 

105 "id", 

106 "url", 

107 "display", 

108 "name", 

109 "description", 

110 "source_adapter", 

111 "adapter_config", 

112 "tags", 

113 "custom_fields", 

114 "created", 

115 "last_updated", 

116 ] 

117 

118 def __init__(self, *args, **kwargs): 

119 """Offer only the runnable adapters on create, so the schema states what REST accepts.""" 

120 super().__init__(*args, **kwargs) 

121 # An update keeps the full registry, so an existing trace profile still round-trips. 

122 if self.instance is None: 

123 self.fields["source_adapter"].choices = selectable_adapter_choices() 

124 

125 def validate(self, attrs): 

126 """Validate the adapter configuration and keep the Source Adapter immutable.""" 

127 instance = self.instance 

128 adapter_key = attrs.get("source_adapter") or getattr(instance, "source_adapter", DEFAULT_ADAPTER_KEY) 

129 if instance is not None and "source_adapter" in attrs and attrs["source_adapter"] != instance.source_adapter: 

130 raise serializers.ValidationError( 

131 {"source_adapter": "The source adapter cannot change after the profile is created."} 

132 ) 

133 adapter = get_adapter(adapter_key) 

134 if adapter is None: 

135 raise serializers.ValidationError({"source_adapter": f"Unknown source adapter '{adapter_key}'."}) 

136 if instance is None: 

137 try: 

138 validate_adapter_target_module(adapter_key) 

139 except ValidationError as exc: 

140 raise serializers.ValidationError(exc.message_dict) from exc 

141 # Normalize unconditionally: this serializer never calls Model.full_clean, so an absent key 

142 # would otherwise persist {} while the form path persists the full mapping. 

143 raw_config = attrs.get("adapter_config", getattr(instance, "adapter_config", None)) 

144 try: 

145 attrs["adapter_config"] = adapter.config_form_class().validate_config(raw_config) 

146 except ValidationError as exc: 

147 raise serializers.ValidationError({"adapter_config": exc.messages}) from exc 

148 return attrs 

149 

150 

151class ColumnMappingSerializer(PolicySectionSerializer): 

152 """Serializer for ColumnMapping (plain model).""" 

153 

154 class Meta: 

155 model = ColumnMapping 

156 fields = ["id", "url", "display", "profile", "source_column", "target_field"] 

157 

158 def validate_policy_row(self, attrs): 

159 """Resolve the target field through the catalog.""" 

160 _validate_target_keys(self.instance, attrs, ("target_field",), required=True) 

161 

162 

163class CableClassMappingSerializer(PolicySectionSerializer): 

164 """Serializer for CableClassMapping policy decisions.""" 

165 

166 class Meta: 

167 model = CableClassMapping 

168 fields = [ 

169 "id", 

170 "url", 

171 "display", 

172 "profile", 

173 "cable_class", 

174 "cable_type_resolved", 

175 "cable_type", 

176 "cable_profile_resolved", 

177 "cable_profile", 

178 ] 

179 

180 

181class PortClassMappingSerializer(PolicySectionSerializer): 

182 """Serializer for PortClassMapping policy decisions.""" 

183 

184 class Meta: 

185 model = PortClassMapping 

186 fields = ["id", "url", "display", "profile", "source_label", "port_class"] 

187 

188 

189class _RackTypeSlugField(serializers.SlugRelatedField): 

190 """SlugRelatedField for RackType that defers the queryset import.""" 

191 

192 def get_queryset(self): 

193 from dcim.models import RackType 

194 

195 return RackType.objects.all() 

196 

197 

198class ClassRoleMappingSerializer(PolicySectionSerializer): 

199 """Serializer for ClassRoleMapping (plain model).""" 

200 

201 rack_type = _RackTypeSlugField(slug_field="slug", allow_null=True, required=False) 

202 

203 class Meta: 

204 model = ClassRoleMapping 

205 fields = ["id", "url", "display", "profile", "source_class", "creates_rack", "rack_type", "role_slug", "ignore"] 

206 

207 

208class DeviceTypeMappingSerializer(PolicySectionSerializer): 

209 """Serializer for DeviceTypeMapping (plain model).""" 

210 

211 class Meta: 

212 model = DeviceTypeMapping 

213 fields = [ 

214 "id", 

215 "url", 

216 "display", 

217 "profile", 

218 "source_make", 

219 "source_model", 

220 "netbox_manufacturer_slug", 

221 "netbox_device_type_slug", 

222 ] 

223 

224 

225class IgnoredDeviceSerializer(PolicySectionSerializer): 

226 """Serializer for IgnoredDevice (plain model).""" 

227 

228 class Meta: 

229 model = IgnoredDevice 

230 fields = ["id", "url", "display", "profile", "source_id", "device_name"] 

231 

232 

233class ColumnTransformRuleSerializer(PolicySectionSerializer): 

234 """Serializer for ColumnTransformRule (plain model).""" 

235 

236 class Meta: 

237 model = ColumnTransformRule 

238 fields = [ 

239 "id", 

240 "url", 

241 "display", 

242 "profile", 

243 "source_column", 

244 "pattern", 

245 "group_1_target", 

246 "group_2_target", 

247 ] 

248 

249 def validate_policy_row(self, attrs): 

250 """Resolve both group targets through the catalog, excluding the candidate targets.""" 

251 _validate_target_keys(self.instance, attrs, ("group_1_target", "group_2_target"), allow_candidates=False) 

252 

253 

254class SourceResolutionSerializer(PolicySectionSerializer): 

255 """Serializer for SourceResolution (rerere, plain model).""" 

256 

257 def validate_profile(self, value): 

258 """Refuse to move a saved row: its source ID and column only mean anything in one profile.""" 

259 # Field validators run before ValidatedModelSerializer.validate() writes onto the instance. 

260 if self.instance is not None and value.pk != self.instance.profile_id: 

261 raise serializers.ValidationError("A saved resolution cannot move to another profile.") 

262 return value 

263 

264 def validate_policy_row(self, attrs): 

265 """Reject Contact candidate resolutions that the importer cannot apply.""" 

266 instance = self.instance 

267 source_column = attrs.get("source_column", getattr(instance, "source_column", None)) 

268 if source_column == "candidate:contact": 

269 profile = attrs.get("profile", getattr(instance, "profile", None)) 

270 original_value = attrs.get("original_value", getattr(instance, "original_value", None)) 

271 resolved_fields = attrs.get("resolved_fields", getattr(instance, "resolved_fields", None)) 

272 try: 

273 candidate_values = json.loads(original_value) 

274 _validate_candidate_values(candidate_values) 

275 configured_sources = profile.column_mappings.filter(target_field="candidate:contact").values_list( 

276 "source_column", flat=True 

277 ) 

278 validate_contact_candidate_resolution( 

279 resolved_fields, 

280 profile.adapter_settings.primary_contact_lookup_field, 

281 set(candidate_values) & set(configured_sources), 

282 ) 

283 except (TypeError, ValueError, json.JSONDecodeError): 

284 raise serializers.ValidationError( 

285 {"original_value": "Enter the Contact candidate values as a JSON object."} 

286 ) from None 

287 except ValidationError as exc: 

288 raise serializers.ValidationError({"resolved_fields": exc.messages}) from exc 

289 

290 class Meta: 

291 model = SourceResolution 

292 fields = [ 

293 "id", 

294 "url", 

295 "display", 

296 "profile", 

297 "source_id", 

298 "source_column", 

299 "original_value", 

300 "resolved_fields", 

301 ] 

302 

303 

304class ImportExecutionSerializer(serializers.ModelSerializer): 

305 """Read-only serializer for the Import Execution audit record.""" 

306 

307 class Meta: 

308 model = ImportExecution 

309 fields = [ 

310 "id", 

311 "profile", 

312 "created", 

313 "input_filename", 

314 "site_name", 

315 "result_counts", 

316 "outcome", 

317 "idempotency_key", 

318 "accepted_plan_fingerprint", 

319 "selected_units", 

320 "applied_changes", 

321 "failure_detail", 

322 ] 

323 read_only_fields = fields 

324 

325 

326class CableImportSourceSerializer(serializers.ModelSerializer): 

327 """Read-only serializer for one Cable provenance record.""" 

328 

329 class Meta: 

330 model = CableImportSource 

331 fields = [ 

332 "id", 

333 "cable", 

334 "profile", 

335 "trace_identity", 

336 "segment_index", 

337 "from_text", 

338 "to_text", 

339 "direction", 

340 "workbook_fingerprint", 

341 "sheet", 

342 "block_ordinal", 

343 "row_start", 

344 "row_end", 

345 "export_timestamp", 

346 ] 

347 read_only_fields = fields 

348 

349 

350class ResolutionProposalSerializer(serializers.ModelSerializer): 

351 """Read-only serializer for the Resolution Proposal audit record.""" 

352 

353 class Meta: 

354 model = ResolutionProposal 

355 fields = [ 

356 "id", 

357 "profile", 

358 "task_type", 

359 "field_key", 

360 "status", 

361 "source_evidence", 

362 "resolved_device_type", 

363 "resolved_device_id", 

364 "prompt_version", 

365 "response_schema_version", 

366 "candidate_snapshot", 

367 "requested_by", 

368 "created", 

369 "last_updated", 

370 "outcome", 

371 "selected_candidate_id", 

372 "selected_object_type", 

373 "selected_object_id", 

374 "explanation", 

375 "backend_metadata", 

376 "response_diagnostic", 

377 "failure_reason", 

378 "decision", 

379 "decided_by", 

380 "decided_at", 

381 "written_resolution", 

382 ] 

383 read_only_fields = fields 

384 

385 

386class ResolutionProposalHistorySerializer(serializers.ModelSerializer): 

387 """Serialize one attempt summary for profile-scoped field history.""" 

388 

389 class Meta: 

390 model = ResolutionProposal 

391 fields = ["id", "created", "status", "outcome", "decision", "failure_reason"] 

392 read_only_fields = fields 

393 

394 

395class InferenceBackendSerializer(NetBoxModelSerializer): 

396 """Serialize one Inference Backend row with a write-only credential reference. 

397 

398 NetBox resolves this class by model name to freeze a delete event payload. 

399 """ 

400 

401 credential_reference = serializers.JSONField(write_only=True) 

402 

403 class Meta: 

404 model = InferenceBackend 

405 fields = [ 

406 "id", 

407 "url", 

408 "display", 

409 *InferenceBackendForm.Meta.fields, 

410 "custom_fields", 

411 "created", 

412 "last_updated", 

413 ] 

414 brief_fields = ["id", "url", "display", "backend_key", "display_name", "enabled"]