Coverage for netbox_data_import/adapter_forms.py: 100%
54 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# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com>
3"""Adapter-declared configuration forms.
5The selected Source Adapter declares the form that validates ``ImportProfile.adapter_config`` at the
6boundary. Unknown keys are rejected. Object references use a natural key, never a database id, so a
7profile exported as YAML imports into a different NetBox instance.
8"""
10from __future__ import annotations
12from django import forms
13from django.core.exceptions import ValidationError
15PREVIEW_VIEW_CHOICES = [
16 ("rows", "Row view"),
17 ("racks", "Rack view"),
18]
20CONTACT_LOOKUP_FIELD_CHOICES = [
21 ("email", "Email address"),
22 ("name", "Name"),
23]
26# Approved exception to the NetBox base-class rule: this validates adapter configuration rather
27# than a model, and NetBox ships no generic non-model form base.
28class AdapterConfigForm(forms.Form):
29 """Base form for an adapter's scalar settings."""
31 @classmethod
32 def defaults(cls) -> dict:
33 """Return the declared default for every key, so an absent key never falls back silently."""
34 return {name: field.initial for name, field in cls.base_fields.items()}
36 @classmethod
37 def validate_config(cls, raw: dict | None) -> dict:
38 """Return the normalized configuration for *raw*, rejecting unknown keys and invalid values."""
39 if raw is None:
40 raw = {}
41 if not isinstance(raw, dict):
42 raise ValidationError({"adapter_config": "Adapter configuration must be a mapping."})
43 unknown = sorted(set(raw) - set(cls.base_fields))
44 if unknown:
45 raise ValidationError({"adapter_config": f"Unknown adapter configuration key(s): {', '.join(unknown)}."})
46 data = cls.defaults()
47 data.update(raw)
48 form = cls(data=data)
49 if not form.is_valid():
50 messages = "; ".join(f"{name}: {' '.join(errors)}" for name, errors in form.errors.items())
51 raise ValidationError({"adapter_config": messages})
52 return form.to_config()
54 @classmethod
55 def normalize(cls, cleaned: dict) -> dict:
56 """Return the stored configuration mapping for one form's cleaned data."""
57 return {name: cleaned.get(name) for name in cls.base_fields}
59 def to_config(self) -> dict:
60 """Return the cleaned data as the stored configuration mapping."""
61 return self.normalize(self.cleaned_data)
64class _ContactRoleNameField(forms.ModelChoiceField):
65 """Reference a Contact Role by its name, so the stored value carries no instance-local id."""
67 def __init__(self, **kwargs):
68 from tenancy.models import ContactRole
70 kwargs.setdefault("queryset", ContactRole.objects.all())
71 kwargs.setdefault("to_field_name", "name")
72 kwargs.setdefault("required", False)
73 super().__init__(**kwargs)
76class FlatWorkbookConfigForm(AdapterConfigForm):
77 """Settings for the flat-workbook adapter."""
79 sheet_name = forms.CharField(
80 max_length=100,
81 initial="Data",
82 help_text="Name of the Excel worksheet to read",
83 )
84 source_id_column = forms.CharField(
85 max_length=100,
86 required=False,
87 initial="",
88 help_text="Column whose value is stored in a NetBox custom field (e.g. 'Id')",
89 )
90 custom_field_name = forms.CharField(
91 max_length=100,
92 required=False,
93 initial="",
94 help_text="NetBox custom field name to store the source ID in (e.g. 'cans_id')",
95 )
96 update_existing = forms.BooleanField(
97 required=False,
98 initial=True,
99 help_text="Update existing NetBox objects when a match is found",
100 )
101 capture_extra_data = forms.BooleanField(
102 required=False,
103 initial=False,
104 help_text="Store unmapped source column values in the import record the plugin keeps for each device.",
105 )
106 primary_contact_role = _ContactRoleNameField(
107 initial=None,
108 help_text="Contact role to assign when a source row contains a primary contact.",
109 )
110 primary_contact_lookup_field = forms.ChoiceField(
111 choices=CONTACT_LOOKUP_FIELD_CHOICES,
112 initial="email",
113 help_text="Contact field used to match primary contact values from the source.",
114 )
115 preview_view_mode = forms.ChoiceField(
116 choices=PREVIEW_VIEW_CHOICES,
117 initial="rows",
118 help_text="How to display the import preview (row table or rack diagrams)",
119 )
121 @classmethod
122 def normalize(cls, cleaned: dict) -> dict:
123 """Return the configuration with the Contact Role reduced to its natural key."""
124 config = super().normalize(cleaned)
125 role = config.get("primary_contact_role")
126 config["primary_contact_role"] = role.name if role is not None else None
127 return config
130class TraceWorkbookConfigForm(AdapterConfigForm):
131 """The trace-workbook adapter declares no settings; its sheet names are fixed."""
134__all__ = (
135 "CONTACT_LOOKUP_FIELD_CHOICES",
136 "PREVIEW_VIEW_CHOICES",
137 "AdapterConfigForm",
138 "FlatWorkbookConfigForm",
139 "TraceWorkbookConfigForm",
140)