diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 384fc053d..6f3f9a8a7 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -5,8 +5,8 @@ from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, - SlugField, + APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, + FilterChoiceField, Livesearch, SmallTextarea, SlugField, ) from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -152,15 +152,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit terminations # -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): +class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.Select( attrs={'filter-for': 'rack'} ) ) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains={'site': 'site'}, required=False, label='Rack', widget=APISelect( @@ -168,8 +169,9 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains={'site': 'site', 'rack': 'rack'}, required=False, label='Device', widget=APISelect( @@ -187,8 +189,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): field_to_update='device' ) ) - interface = forms.ModelChoiceField( - queryset=Interface.objects.all(), + interface = ChainedModelChoiceField( + queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ), + chains={'device': 'device'}, required=False, label='Interface', widget=APISelect( @@ -210,51 +215,16 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'term_side': forms.HiddenInput(), } - def __init__(self, *args, **kwargs): + def __init__(self, instance=None, initial=None, *args, **kwargs): - super(CircuitTerminationForm, self).__init__(*args, **kwargs) + # Initialize helper selectors + if instance and instance.interface is not None: + initial['rack'] = instance.interface.device.rack + initial['device'] = instance.interface.device - # If an interface has been assigned, initialize rack and device - if self.instance.interface: - self.initial['rack'] = self.instance.interface.device.rack - self.initial['device'] = self.instance.interface.device + super(CircuitTerminationForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) - # Limit rack choices - if self.is_bound: - self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Limit device choices - if self.is_bound and self.data.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack']) - elif self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - else: - self.fields['device'].choices = [] - - # Limit interface choices - if self.is_bound and self.data.get('device'): - interfaces = Interface.objects.filter(device=self.data['device']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') - elif self.initial.get('device'): - interfaces = Interface.objects.filter(device=self.initial['device']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') - else: - interfaces = [] + # Mark connected interfaces as disabled self.fields['interface'].choices = [ - (iface.id, { - 'label': iface.name, - 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'), - }) for iface in interfaces + (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 79fb865df..896e91f6a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -11,8 +11,9 @@ from ipam.models import IPAddress from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, - Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, + BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField, + FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField @@ -184,16 +185,23 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): # Racks # -class RackForm(BootstrapMixin, CustomFieldForm): - group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( - api_url='/api/dcim/rack-groups/?site_id={{site}}', - )) +class RackForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): + group = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + chains={'site': 'site'}, + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/?site_id={{site}}', + ) + ) comments = CommentField() class Meta: model = Rack - fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', - 'comments'] + fields = [ + 'site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'comments', + ] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", @@ -204,18 +212,6 @@ class RackForm(BootstrapMixin, CustomFieldForm): 'site': forms.Select(attrs={'filter-for': 'group'}), } - def __init__(self, *args, **kwargs): - - super(RackForm, self).__init__(*args, **kwargs) - - # Limit rack group choices - if self.is_bound and self.data.get('site'): - self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site']) - else: - self.fields['group'].choices = [] - class RackFromCSVForm(forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', @@ -538,25 +534,46 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): # Devices # -class DeviceForm(BootstrapMixin, CustomFieldForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), required=False, widget=APISelect( +class DeviceForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains={'site': 'site'}, + required=False, + widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'position'} ) ) position = forms.TypedChoiceField( - required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device", - widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device') + required=False, + empty_value=None, + help_text="The lowest-numbered unit occupied by the device", + widget=APISelect( + api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', + disabled_indicator='device' + ) ) manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'}) + queryset=Manufacturer.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'device_type'} + ) ) - device_type = forms.ModelChoiceField( - queryset=DeviceType.objects.all(), label='Device type', - widget=APISelect(api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', display_field='model') + device_type = ChainedModelChoiceField( + queryset=DeviceType.objects.all(), + chains={'manufacturer': 'manufacturer'}, + label='Device type', + widget=APISelect( + api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', + display_field='model' + ) ) comments = CommentField() @@ -572,19 +589,18 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): } widgets = { 'face': forms.Select(attrs={'filter-for': 'position'}), - 'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}), } - def __init__(self, *args, **kwargs): + def __init__(self, instance=None, initial=None, *args, **kwargs): - super(DeviceForm, self).__init__(*args, **kwargs) + # Initialize helper selections + if instance and instance.device_type is not None: + initial['manufacturer'] = instance.device_type.manufacturer + + super(DeviceForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) if self.instance.pk: - # Initialize helper selections - self.initial['site'] = self.instance.site - self.initial['manufacturer'] = self.instance.device_type.manufacturer - # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [] @@ -607,14 +623,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].widget.attrs['readonly'] = True - # Limit rack choices - if self.is_bound and self.data.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - # Rack position pk = self.instance.pk if self.instance.pk else None try: @@ -635,16 +643,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): }) for p in position_choices ] - # Limit device_type choices - if self.is_bound: - self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\ - .select_related('manufacturer') - elif self.initial.get('manufacturer'): - self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\ - .select_related('manufacturer') - else: - self.fields['device_type'].choices = [] - # Disable rack assignment if this is a child device installed in a parent device if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True @@ -940,21 +938,23 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): self.cleaned_data['csv'] = connection_list -class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): +class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.HiddenInput(), ) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains={'site': 'site'}, label='Rack', required=False, widget=forms.Select( attrs={'filter-for': 'console_server', 'nullable': 'true'} ) ) - console_server = forms.ModelChoiceField( - queryset=Device.objects.all(), + console_server = ChainedModelChoiceField( + queryset=Device.objects.filter(device_type__is_console_server=True), + chains={'site': 'site', 'rack': 'rack'}, label='Console Server', required=False, widget=APISelect( @@ -972,8 +972,9 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): field_to_update='console_server', ) ) - cs_port = forms.ModelChoiceField( + cs_port = ChainedModelChoiceField( queryset=ConsoleServerPort.objects.all(), + chains={'device': 'console_server'}, label='Port', widget=APISelect( api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', @@ -996,32 +997,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize console_server choices if rack or site is set - if self.initial.get('rack'): - self.fields['console_server'].queryset = Device.objects.filter( - rack=self.initial['rack'], device_type__is_console_server=True - ) - elif self.initial.get('site'): - self.fields['console_server'].queryset = Device.objects.filter( - site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True - ) - else: - self.fields['console_server'].choices = [] - - # Initialize CS port choices if console_server is set - if self.initial.get('console_server'): - self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter( - device=self.initial['console_server'] - ) - else: - self.fields['cs_port'].choices = [] - # # Console server ports @@ -1041,21 +1016,23 @@ class ConsoleServerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): +class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.HiddenInput(), ) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains={'site': 'site'}, label='Rack', required=False, widget=forms.Select( attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains={'site': 'site', 'rack': 'rack'}, label='Device', required=False, widget=APISelect( @@ -1073,8 +1050,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): field_to_update='device' ) ) - port = forms.ModelChoiceField( + port = ChainedModelChoiceField( queryset=ConsolePort.objects.all(), + chains={'device': 'device'}, label='Port', widget=APISelect( api_url='/api/dcim/console-ports/?device_id={{device}}', @@ -1096,30 +1074,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): 'connection_status': 'Status', } - def __init__(self, *args, **kwargs): - - super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs) - - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize device choices if rack or site is set - if self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - elif self.initial.get('site'): - self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True) - else: - self.fields['device'].choices = [] - - # Initialize port choices if device is set - if self.initial.get('device'): - self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device']) - else: - self.fields['port'].choices = [] - # # Power ports @@ -1211,18 +1165,20 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): self.cleaned_data['csv'] = connection_list -class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): +class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput()) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains={'site': 'site'}, label='Rack', required=False, widget=forms.Select( attrs={'filter-for': 'pdu', 'nullable': 'true'} ) ) - pdu = forms.ModelChoiceField( + pdu = ChainedModelChoiceField( queryset=Device.objects.all(), + chains={'site': 'site', 'rack': 'rack'}, label='PDU', required=False, widget=APISelect( @@ -1240,8 +1196,9 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): field_to_update='pdu' ) ) - power_outlet = forms.ModelChoiceField( + power_outlet = ChainedModelChoiceField( queryset=PowerOutlet.objects.all(), + chains={'device': 'device'}, label='Outlet', widget=APISelect( api_url='/api/dcim/power-outlets/?device_id={{pdu}}', @@ -1264,30 +1221,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize pdu choices if rack or site is set - if self.initial.get('rack'): - self.fields['pdu'].queryset = Device.objects.filter( - rack=self.initial['rack'], device_type__is_pdu=True - ) - elif self.initial.get('site'): - self.fields['pdu'].queryset = Device.objects.filter( - site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True - ) - else: - self.fields['pdu'].choices = [] - - # Initialize power outlet choices if pdu is set - if self.initial.get('pdu'): - self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu']) - else: - self.fields['power_outlet'].choices = [] - # # Power outlets @@ -1307,21 +1240,23 @@ class PowerOutletCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class PowerOutletConnectionForm(BootstrapMixin, forms.Form): +class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.HiddenInput() ) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains={'site': 'site'}, label='Rack', required=False, widget=forms.Select( attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains={'site': 'site', 'rack': 'rack'}, label='Device', required=False, widget=APISelect( @@ -1339,8 +1274,9 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): field_to_update='device' ) ) - port = forms.ModelChoiceField( + port = ChainedModelChoiceField( queryset=PowerPort.objects.all(), + chains={'device': 'device'}, label='Port', widget=APISelect( api_url='/api/dcim/power-ports/?device_id={{device}}', @@ -1362,30 +1298,6 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): 'connection_status': 'Status', } - def __init__(self, *args, **kwargs): - - super(PowerOutletConnectionForm, self).__init__(*args, **kwargs) - - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize device choices if rack or site is set - if self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - elif self.initial.get('site'): - self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True) - else: - self.fields['device'].choices = [] - - # Initialize port choices if device is set - if self.initial.get('device'): - self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device']) - else: - self.fields['port'].choices = [] - # # Interfaces @@ -1468,7 +1380,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # Interface connections # -class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): +class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): interface_a = forms.ChoiceField( choices=[], widget=SelectWithDisabled, @@ -1482,8 +1394,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'rack_b'} ) ) - rack_b = forms.ModelChoiceField( + rack_b = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains = {'site': 'site_b'}, label='Rack', required=False, widget=APISelect( @@ -1491,8 +1404,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'device_b', 'nullable': 'true'} ) ) - device_b = forms.ModelChoiceField( + device_b = ChainedModelChoiceField( queryset=Device.objects.all(), + chains = {'site': 'site_b', 'rack': 'rack_b'}, label='Device', required=False, widget=APISelect( @@ -1510,8 +1424,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): field_to_update='device_b' ) ) - interface_b = forms.ModelChoiceField( - queryset=Interface.objects.all(), + interface_b = ChainedModelChoiceField( + queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ), + chains = {'device': 'device_b'}, label='Interface', widget=APISelect( api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', @@ -1537,31 +1454,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces ] - # Initialize rack_b choices if site_b is set - if self.initial.get('site_b'): - self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b']) - else: - self.fields['rack_b'].choices = [] - - # Initialize device_b choices if rack_b or site_b is set - if self.initial.get('rack_b'): - self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b']) - elif self.initial.get('site_b'): - self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True) - else: - self.fields['device_b'].choices = [] - - # Initialize interface_b choices if device_b is set - if self.initial.get('device_b'): - device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - else: - device_b_interfaces = [] + # Mark connected interfaces as disabled self.fields['interface_b'].choices = [ - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces + (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset ] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 439f1bf9e..de610edf2 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,8 +5,8 @@ from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, CSVDataField, ExpandableIPAddressField, - FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, + APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, + CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ) from .models import ( @@ -163,12 +163,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm): # Prefixes # -class PrefixForm(BootstrapMixin, CustomFieldForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'})) - vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', - widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', - display_field='display_name')) +class PrefixForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( + attrs={'filter-for': 'vlan', 'nullable': 'true'} + ) + ) + vlan = ChainedModelChoiceField( + queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect( + api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name' + ) + ) class Meta: model = Prefix @@ -179,14 +184,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' - # Initialize field without choices to avoid pulling all VLANs from the database - if self.is_bound and self.data.get('site'): - self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) - else: - self.fields['vlan'].queryset = VLAN.objects.filter(site=None) - class PrefixFromCSVForm(forms.ModelForm): vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', @@ -214,7 +211,6 @@ class PrefixFromCSVForm(forms.ModelForm): vlan_group_name = self.cleaned_data.get('vlan_group_name') vlan_vid = self.cleaned_data.get('vlan_vid') vlan_group = None - vlan = None # Validate VLAN group if vlan_group_name: @@ -310,38 +306,93 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # IP addresses # -class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, ChainedFieldsMixin, ReturnURLForm, CustomFieldForm): interface_site = forms.ModelChoiceField( - queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( + queryset=Site.objects.all(), + required=False, + label='Site', + widget=forms.Select( attrs={'filter-for': 'interface_rack'} ) ) - interface_rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect( - api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name', + interface_rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains={'site': 'interface_site'}, + required=False, + label='Rack', + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{interface_site}}', + display_field='display_name', attrs={'filter-for': 'interface_device', 'nullable': 'true'} ) ) - interface_device = forms.ModelChoiceField( - queryset=Device.objects.all(), required=False, label='Device', widget=APISelect( + interface_device = ChainedModelChoiceField( + queryset=Device.objects.all(), + chains={'site': 'interface_site', 'rack': 'interface_rack'}, + required=False, + label='Device', + widget=APISelect( api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}', - display_field='display_name', attrs={'filter-for': 'interface'} + display_field='display_name', + attrs={'filter-for': 'interface'} + ) + ) + interface = ChainedModelChoiceField( + queryset=Interface.objects.all(), + chains={'device': 'interface_device'}, + required=False, + widget=APISelect( + api_url='/api/dcim/interfaces/?device_id={{interface_device}}' ) ) nat_site = forms.ModelChoiceField( - queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( + queryset=Site.objects.all(), + required=False, + label='Site', + widget=forms.Select( attrs={'filter-for': 'nat_device'} ) ) - nat_device = forms.ModelChoiceField( - queryset=Device.objects.all(), required=False, label='Device', widget=APISelect( - api_url='/api/dcim/devices/?site_id={{nat_site}}', display_field='display_name', + nat_rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains={'site': 'nat_site'}, + required=False, + label='Rack', + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{interface_site}}', + display_field='display_name', + attrs={'filter-for': 'nat_device', 'nullable': 'true'} + ) + ) + nat_device = ChainedModelChoiceField( + queryset=Device.objects.all(), + chains={'site': 'nat_site'}, + required=False, + label='Device', + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{nat_site}}', + display_field='display_name', attrs={'filter-for': 'nat_inside'} ) ) + nat_inside = ChainedModelChoiceField( + queryset=IPAddress.objects.all(), + chains={'interface__device': 'nat_device'}, + required=False, + label='IP Address', + widget=APISelect( + api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', + display_field='address' + ) + ) livesearch = forms.CharField( - required=False, label='IP Address', widget=Livesearch( - query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address' + required=False, + label='IP Address', + widget=Livesearch( + query_key='q', + query_url='ipam-api:ipaddress-list', + field_to_update='nat_inside', + obj_label='address' ) ) primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device') @@ -349,46 +400,25 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): class Meta: model = IPAddress fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside'] - widgets = { - 'interface': APISelect(api_url='/api/dcim/interfaces/?device_id={{interface_device}}'), - 'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address') - } - def __init__(self, *args, **kwargs): - super(IPAddressForm, self).__init__(*args, **kwargs) + def __init__(self, instance=None, initial=None, *args, **kwargs): + + # Initialize interface selectors + if instance and instance.interface is not None: + initial['interface_site'] = instance.interface.device.site + initial['interface_rack'] = instance.interface.device.rack + initial['interface_device'] = instance.interface.device + + # Initialize NAT selectors + if instance and instance.nat_inside is not None: + initial['nat_site'] = instance.nat_inside.device.site + initial['nat_rack'] = instance.nat_inside.device.rack + initial['nat_device'] = instance.nat_inside.device + + super(IPAddressForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) self.fields['vrf'].empty_label = 'Global' - # If an interface has been assigned, initialize site, rack, and device - if self.instance.interface: - self.initial['interface_site'] = self.instance.interface.device.site - self.initial['interface_rack'] = self.instance.interface.device.rack - self.initial['interface_device'] = self.instance.interface.device - - # Limit rack choices - if self.is_bound and self.data.get('interface_site'): - self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site']) - elif self.initial.get('interface_site'): - self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site']) - else: - self.fields['interface_rack'].choices = [] - - # Limit device choices - if self.is_bound and self.data.get('interface_rack'): - self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack']) - elif self.initial.get('interface_rack'): - self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack']) - else: - self.fields['interface_device'].choices = [] - - # Limit interface choices - if self.is_bound and self.data.get('interface_device'): - self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device']) - elif self.initial.get('interface_device'): - self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device']) - else: - self.fields['interface'].choices = [] - # Initialize primary_for_device if IP address is already assigned if self.instance.interface is not None: device = self.instance.interface.device @@ -398,38 +428,6 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): ): self.initial['primary_for_device'] = True - if self.instance.nat_inside: - nat_inside = self.instance.nat_inside - # If the IP is assigned to an interface, populate site/device fields accordingly - if self.instance.nat_inside.interface: - self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk - self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk - self.fields['nat_device'].queryset = Device.objects.filter( - site=nat_inside.interface.device.site - ) - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device=nat_inside.interface.device - ) - else: - self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk) - else: - # Initialize nat_device choices if nat_site is set - if self.is_bound and self.data.get('nat_site'): - self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site']) - elif self.initial.get('nat_site'): - self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site']) - else: - self.fields['nat_device'].choices = [] - # Initialize nat_inside choices if nat_device is set - if self.is_bound and self.data.get('nat_device'): - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device__pk=self.data['nat_device']) - elif self.initial.get('nat_device'): - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device__pk=self.initial['nat_device']) - else: - self.fields['nat_inside'].choices = [] - def clean(self): super(IPAddressForm, self).clean() @@ -602,10 +600,22 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, CustomFieldForm): - group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect( - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - )) +class VLANForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'group', 'nullable': 'true'} + ) + ) + group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains={'site': 'site'}, + required=False, + label='Group', + widget=APISelect( + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + ) + ) class Meta: model = VLAN @@ -618,21 +628,6 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 'status': "Operational status of this VLAN", 'role': "The primary function of this VLAN", } - widgets = { - 'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}), - } - - def __init__(self, *args, **kwargs): - - super(VLANForm, self).__init__(*args, **kwargs) - - # Limit VLAN group choices - if self.is_bound and self.data.get('site'): - self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) - else: - self.fields['group'].queryset = VLANGroup.objects.filter(site=None) class VLANFromCSVForm(forms.ModelForm): @@ -663,7 +658,7 @@ class VLANFromCSVForm(forms.ModelForm): group_name = self.cleaned_data.get('group_name') if group_name: try: - vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name) + VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name) except VLANGroup.DoesNotExist: self.add_error('group_name', "Invalid VLAN group {}.".format(group_name)) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 87d2636d8..612a89922 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -2,15 +2,12 @@ from django_tables2 import RequestConfig import netaddr from django.conf import settings -from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin -from django.contrib import messages from django.db.models import Count, Q -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, render from django.urls import reverse from dcim.models import Device -from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index d14c56e7b..75ac82ade 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -331,6 +331,25 @@ class FlexibleModelChoiceField(forms.ModelChoiceField): return value +class ChainedModelChoiceField(forms.ModelChoiceField): + """ + A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary + mapping of model fields to peer fields within the form. For example: + + country1 = forms.ModelChoiceField(queryset=Country.objects.all()) + city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'} + + The queryset of the `city1` field will be modified as + + .filter(country=) + + where is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.) + """ + def __init__(self, chains=None, *args, **kwargs): + self.chains = chains + super(ChainedModelChoiceField, self).__init__(*args, **kwargs) + + class SlugField(forms.SlugField): def __init__(self, slug_source='name', *args, **kwargs): @@ -411,6 +430,32 @@ class BootstrapMixin(forms.BaseForm): field.widget.attrs['placeholder'] = field.label +class ChainedFieldsMixin(forms.BaseForm): + """ + Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. + """ + def __init__(self, *args, **kwargs): + super(ChainedFieldsMixin, self).__init__(*args, **kwargs) + + for field_name, field in self.fields.items(): + + if isinstance(field, ChainedModelChoiceField): + + filters_dict = {} + for db_field, parent_field in field.chains.items(): + if self.is_bound and self.data.get(parent_field): + filters_dict[db_field] = self.data.get(parent_field) + elif self.initial.get(parent_field): + filters_dict[db_field] = self.initial[parent_field] + else: + filters_dict[db_field] = None + + if filters_dict: + field.queryset = field.queryset.filter(**filters_dict) + else: + field.queryset = field.queryset.none() + + class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted.