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

1# SPDX-License-Identifier: Apache-2.0 

2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Adapter-declared configuration forms. 

4 

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""" 

9 

10from __future__ import annotations 

11 

12from django import forms 

13from django.core.exceptions import ValidationError 

14 

15PREVIEW_VIEW_CHOICES = [ 

16 ("rows", "Row view"), 

17 ("racks", "Rack view"), 

18] 

19 

20CONTACT_LOOKUP_FIELD_CHOICES = [ 

21 ("email", "Email address"), 

22 ("name", "Name"), 

23] 

24 

25 

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.""" 

30 

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()} 

35 

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() 

53 

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} 

58 

59 def to_config(self) -> dict: 

60 """Return the cleaned data as the stored configuration mapping.""" 

61 return self.normalize(self.cleaned_data) 

62 

63 

64class _ContactRoleNameField(forms.ModelChoiceField): 

65 """Reference a Contact Role by its name, so the stored value carries no instance-local id.""" 

66 

67 def __init__(self, **kwargs): 

68 from tenancy.models import ContactRole 

69 

70 kwargs.setdefault("queryset", ContactRole.objects.all()) 

71 kwargs.setdefault("to_field_name", "name") 

72 kwargs.setdefault("required", False) 

73 super().__init__(**kwargs) 

74 

75 

76class FlatWorkbookConfigForm(AdapterConfigForm): 

77 """Settings for the flat-workbook adapter.""" 

78 

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 ) 

120 

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 

128 

129 

130class TraceWorkbookConfigForm(AdapterConfigForm): 

131 """The trace-workbook adapter declares no settings; its sheet names are fixed.""" 

132 

133 

134__all__ = ( 

135 "CONTACT_LOOKUP_FIELD_CHOICES", 

136 "PREVIEW_VIEW_CHOICES", 

137 "AdapterConfigForm", 

138 "FlatWorkbookConfigForm", 

139 "TraceWorkbookConfigForm", 

140)