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
« 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 _
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
15__all__ = (
16 'FrontPortFormMixin',
17 'ScopedBulkEditForm',
18 'ScopedForm',
19 'ScopedImportForm',
20)
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 )
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 )
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 )
55 def clean(self):
56 super().clean()
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')
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."))
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"))
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 })
102class FrontPortFormMixin(forms.Form):
103 rear_ports = forms.MultipleChoiceField(
104 choices=[],
105 label=_('Rear ports'),
106 widget=forms.SelectMultiple(attrs={'size': 8})
107 )
109 def clean(self):
110 super().clean()
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
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)
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 })
136 def _save_m2m(self):
137 super()._save_m2m()
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 })
151 reconcile_port_mappings(
152 self.port_mapping_model,
153 parent_field='front_port',
154 parent=self.instance,
155 desired=desired,
156 )
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 ]
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