Coverage for dcim/forms/mixins.py: 25%

70 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from django import forms 

2from django.contrib.contenttypes.models import ContentType 

3from django.core.exceptions import ValidationError 

4from django.utils.translation import gettext_lazy as _ 

5 

6from dcim.constants import LOCATION_SCOPE_TYPES 

7from dcim.utils import reconcile_port_mappings 

8from utilities.forms import GenericObjectFormMixin 

9from utilities.forms.fields import ( 

10 CSVContentTypeField, 

11 GenericObjectChoiceField, 

12) 

13from utilities.templatetags.builtins.filters import bettertitle 

14 

15__all__ = ( 

16 'FrontPortFormMixin', 

17 'ScopedBulkEditForm', 

18 'ScopedForm', 

19 'ScopedImportForm', 

20) 

21 

22 

23class ScopedForm(GenericObjectFormMixin, forms.Form): 

24 scope = GenericObjectChoiceField( 

25 label=_('Scope'), 

26 content_type_queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), 

27 required=False, 

28 selector=True, 

29 hx_target_id='scope', 

30 ) 

31 

32 

33class ScopedBulkEditForm(GenericObjectFormMixin, forms.Form): 

34 scope = GenericObjectChoiceField( 

35 label=_('Scope'), 

36 content_type_queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), 

37 required=False, 

38 selector=True, 

39 hx_method='post', 

40 ) 

41 

42 

43class ScopedImportForm(forms.Form): 

44 scope_type = CSVContentTypeField( 

45 queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), 

46 required=False, 

47 label=_('Scope type (app & model)') 

48 ) 

49 scope_name = forms.CharField( 

50 required=False, 

51 label=_('Scope name'), 

52 help_text=_('Name of the assigned scope object (if not using ID)') 

53 ) 

54 

55 def clean(self): 

56 super().clean() 

57 

58 scope_id = self.cleaned_data.get('scope_id') 

59 scope_name = self.cleaned_data.get('scope_name') 

60 scope_type = self.cleaned_data.get('scope_type') 

61 

62 # Cannot specify both scope_name and scope_id 

63 if scope_name and scope_id: 

64 raise ValidationError(_("scope_name and scope_id are mutually exclusive.")) 

65 

66 # Must specify scope_type with scope_name or scope_id 

67 if scope_name and not scope_type: 

68 raise ValidationError(_("scope_type must be specified when using scope_name")) 

69 if scope_id and not scope_type: 

70 raise ValidationError(_("scope_type must be specified when using scope_id")) 

71 

72 # Look up the scope object by name 

73 if scope_type and scope_name: 

74 model = scope_type.model_class() 

75 try: 

76 scope_obj = model.objects.get(name=scope_name) 

77 except model.DoesNotExist: 

78 raise ValidationError({ 

79 'scope_name': _('{scope_type} "{name}" not found.').format( 

80 scope_type=bettertitle(model._meta.verbose_name), 

81 name=scope_name 

82 ) 

83 }) 

84 except model.MultipleObjectsReturned: 

85 raise ValidationError({ 

86 'scope_name': _( 

87 'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.' 

88 ).format( 

89 scope_type=bettertitle(model._meta.verbose_name), 

90 name=scope_name, 

91 ) 

92 }) 

93 self.cleaned_data['scope_id'] = scope_obj.pk 

94 elif scope_type and not scope_id: 

95 raise ValidationError({ 

96 'scope_id': _( 

97 "Please select a {scope_type}." 

98 ).format(scope_type=scope_type.model_class()._meta.model_name) 

99 }) 

100 

101 

102class FrontPortFormMixin(forms.Form): 

103 rear_ports = forms.MultipleChoiceField( 

104 choices=[], 

105 label=_('Rear ports'), 

106 widget=forms.SelectMultiple(attrs={'size': 8}) 

107 ) 

108 

109 def clean(self): 

110 super().clean() 

111 

112 # All three are required fields, so bail out if any of them failed its own validation 

113 positions = self.cleaned_data.get('positions') 

114 name = self.cleaned_data.get('name') 

115 rear_ports = self.cleaned_data.get('rear_ports') 

116 if not (positions and name and rear_ports): 

117 return 

118 

119 # `name` is a list under FrontPortCreateForm, and each generated FrontPort consumes `positions` mappings 

120 frontport_count = len(name) if isinstance(name, list) else 1 

121 frontport_position_count = frontport_count * positions 

122 rearport_count = len(rear_ports) 

123 

124 # {frontport_count} receives the position total. Its name is unchanged to keep existing translations valid. 

125 if frontport_position_count != rearport_count: 

126 raise forms.ValidationError({ 

127 'rear_ports': _( 

128 "The total number of front port positions ({frontport_count}) must match the selected number of " 

129 "rear port positions ({rearport_count})." 

130 ).format( 

131 frontport_count=frontport_position_count, 

132 rearport_count=rearport_count 

133 ) 

134 }) 

135 

136 def _save_m2m(self): 

137 super()._save_m2m() 

138 

139 # Build the desired set of mappings from the submitted rear port pairs, assigning front port 

140 # positions in order. reconcile_port_mappings() then writes only the difference, so re-saving 

141 # a front port without changing its wiring produces no writes (and no changelog churn). 

142 desired = [] 

143 for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): 

144 rear_port_id, rear_port_position = rp_position.split(':') 

145 desired.append({ 

146 'front_port_position': i, 

147 'rear_port_id': int(rear_port_id), 

148 'rear_port_position': int(rear_port_position), 

149 }) 

150 

151 reconcile_port_mappings( 

152 self.port_mapping_model, 

153 parent_field='front_port', 

154 parent=self.instance, 

155 desired=desired, 

156 ) 

157 

158 def _get_rear_port_choices(self, parent_filter, front_port): 

159 """ 

160 Return a list of choices representing each available rear port & position pair on the parent object (identified 

161 by a Q filter), excluding those assigned to the specified instance. 

162 """ 

163 occupied_rear_port_positions = [ 

164 f'{mapping.rear_port_id}:{mapping.rear_port_position}' 

165 for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk) 

166 ] 

167 

168 choices = [] 

169 for rear_port in self.rear_port_model.objects.filter(parent_filter): 

170 for i in range(1, rear_port.positions + 1): 

171 pair_id = f'{rear_port.pk}:{i}' 

172 if pair_id not in occupied_rear_port_positions: 

173 pair_label = f'{rear_port.name}:{i}' 

174 choices.append((pair_id, pair_label)) 

175 return choices