diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 6a058fddc..44e7ee533 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -25,7 +25,7 @@ server { server_name netbox.example.com; - access_log off; + client_max_body_size 25m; location /static/ { alias /opt/netbox/netbox/static/; diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 384fc053d..f81abff04 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,10 +3,11 @@ from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from tenancy.forms import TenancyForm 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 @@ -83,12 +84,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): # Circuits # -class CircuitForm(BootstrapMixin, CustomFieldForm): +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() class Meta: model = Circuit - fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] + fields = [ + 'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', + 'comments', + ] help_texts = { 'cid': "Unique circuit ID", 'install_date': "Format: YYYY-MM-DD", @@ -152,15 +156,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 +173,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 +193,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( @@ -212,49 +221,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): + # Initialize helper selectors + instance = kwargs.get('instance') + if instance and instance.interface is not None: + initial = kwargs.get('initial', {}) + initial['rack'] = instance.interface.device.rack + initial['device'] = instance.interface.device + kwargs['initial'] = initial + super(CircuitTerminationForm, self).__init__(*args, **kwargs) - # 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 - - # 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/circuits/tables.py b/netbox/circuits/tables.py index 07e2c4477..3cda30ccc 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -79,7 +79,13 @@ class CircuitSearchTable(SearchTable): cid = tables.LinkColumn(verbose_name='ID') provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + a_side = tables.LinkColumn( + 'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')] + ) + z_side = tables.LinkColumn( + 'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')] + ) class Meta(SearchTable.Meta): model = Circuit - fields = ('cid', 'type', 'provider', 'tenant', 'description') + fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a9c52e3fd..4f02db847 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -608,6 +608,7 @@ class InterfaceSerializer(serializers.ModelSerializer): class PeerInterfaceSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) class Meta: model = Interface diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 79fb865df..414e54306 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -8,11 +8,13 @@ from django.db.models import Count, Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress +from tenancy.forms import TenancyForm 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 @@ -80,7 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): # Sites # -class SiteForm(BootstrapMixin, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() @@ -88,8 +90,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -184,16 +186,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, TenancyForm, 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_group', '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 +213,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,33 +535,54 @@ 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, TenancyForm, 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() class Meta: model = Device fields = [ - 'name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments', + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', + 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', ] help_texts = { 'device_role': "The function this device serves", @@ -572,19 +590,22 @@ 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): + # Initialize helper selectors + instance = kwargs.get('instance') + # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field + if instance and hasattr(instance, 'device_type'): + initial = kwargs.get('initial', {}) + initial['manufacturer'] = instance.device_type.manufacturer + kwargs['initial'] = initial + super(DeviceForm, self).__init__(*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 +628,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 +648,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 @@ -811,6 +814,10 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), label='Rack group', ) + rack_id = FilterChoiceField( + queryset=Rack.objects.annotate(filter_count=Count('devices')), + label='Rack', + ) role = FilterChoiceField( queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug', @@ -940,21 +947,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 +981,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 +1006,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 +1025,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 +1059,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 +1083,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 +1174,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 +1205,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 +1230,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 +1249,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 +1283,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 +1307,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 +1389,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 +1403,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 +1413,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 +1433,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 +1463,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/dcim/models.py b/netbox/dcim/models.py index f991d5796..59d7a0ef2 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -410,7 +410,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ] def __str__(self): - return self.display_name + return self.display_name or super(Rack, self).__str__() def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) @@ -467,7 +467,9 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): def display_name(self): if self.facility_id: return u"{} ({})".format(self.name, self.facility_id) - return self.name + elif self.name: + return self.name + return u"" def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): """ @@ -810,13 +812,13 @@ class InterfaceManager(models.Manager): def order_naturally(self, method=IFACE_ORDERING_POSITION): """ - Naturally order interfaces by their name and numeric position. The sort method must be one of the defined + Naturally order interfaces by their type and numeric position. The sort method must be one of the defined IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - To order interfaces naturally, the `name` field is split into five distinct components: leading text (name), + To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), slot, subslot, position, channel, and virtual circuit: - {name}{slot}/{subslot}/{position}:{channel}.{vc} + {type}{slot}/{subslot}/{position}:{channel}.{vc} Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would be parsed as follows: @@ -828,16 +830,17 @@ class InterfaceManager(models.Manager): channel = None vc = 0 - The chosen sorting method will determine which fields are ordered first in the query. + The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of + the prescribed fields. """ queryset = self.get_queryset() sql_col = '{}.name'.format(queryset.model._meta.db_table) ordering = { - IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'), - IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'), + IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'), + IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'), }[method] return queryset.extra(select={ - '_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), + '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), @@ -983,7 +986,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): unique_together = ['rack', 'position', 'face'] def __str__(self): - return self.display_name + return self.display_name or super(Device, self).__str__() def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) @@ -1102,12 +1105,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel): def display_name(self): if self.name: return self.name - elif self.position: - return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position) - elif self.rack: - return u"{} ({})".format(self.device_type, self.rack.name) - else: - return u"{} ({})".format(self.device_type, self.site.name) + elif hasattr(self, 'device_type'): + return u"{}".format(self.device_type) + return u"" @property def identifier(self): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 15755cbe9..4ffe2d36b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -105,6 +105,9 @@ class ComponentCreateView(View): new_components.append(component_form.save(commit=False)) else: for field, errors in component_form.errors.as_data().items(): + # Assign errors on the child form's name field to name_pattern on the parent form + if field == 'name': + field = 'name_pattern' for e in errors: form.add_error(field, u'{}: {}'.format(name, ', '.join(e))) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 62eb07d1a..66d44d8a5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -13,6 +13,8 @@ from django.template import Template, Context from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe +from utilities.utils import foreground_color + CUSTOMFIELD_MODELS = ( 'site', 'rack', 'devicetype', 'device', # DCIM @@ -316,7 +318,7 @@ class TopologyMap(models.Model): def render(self, img_format='png'): from circuits.models import CircuitTermination - from dcim.models import Device, InterfaceConnection + from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection # Construct the graph graph = graphviz.Graph() @@ -336,8 +338,9 @@ class TopologyMap(models.Model): for query in device_set.split(';'): # Split regexes on semicolons devices += Device.objects.filter(name__regex=query).select_related('device_role') for d in devices: - fillcolor = '#{}'.format(d.device_role.color) - subgraph.node(d.name, style='filled', fillcolor=fillcolor) + bg_color = '#{}'.format(d.device_role.color) + fg_color = '#{}'.format(foreground_color(d.device_role.color)) + subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans') # Add an invisible connection to each successive device in a set to enforce horizontal order for j in range(0, len(devices) - 1): @@ -357,7 +360,8 @@ class TopologyMap(models.Model): interface_a__device__in=devices, interface_b__device__in=devices ) for c in connections: - graph.edge(c.interface_a.device.name, c.interface_b.device.name) + style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' + graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) # Add all circuits to the graph for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 439f1bf9e..ec596d612 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -3,10 +3,11 @@ from django.db.models import Count from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from tenancy.forms import TenancyForm 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, ChainedModelChoiceField, CSVDataField, + ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ) from .models import ( @@ -32,11 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)] # VRFs # -class VRFForm(BootstrapMixin, CustomFieldForm): +class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VRF - fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant'] labels = { 'rd': "RD", } @@ -163,30 +164,27 @@ 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, TenancyForm, 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 - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description'] + fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] def __init__(self, *args, **kwargs): super(PrefixForm, self).__init__(*args, **kwargs) 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 +212,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,85 +307,123 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # IP addresses # -class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, TenancyForm, 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') 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') - } + fields = [ + 'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group', + 'tenant', + ] def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + 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 + 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 + kwargs['initial'] = initial + super(IPAddressForm, self).__init__(*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 +433,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() @@ -468,15 +471,19 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): return ipaddress -class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm): - address_pattern = ExpandableIPAddressField(label='Address Pattern') - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global') +class IPAddressPatternForm(BootstrapMixin, forms.Form): + pattern = ExpandableIPAddressField(label='Address pattern') - pattern_map = ('address_pattern', 'address') + +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress - fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description'] + fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant'] + + def __init__(self, *args, **kwargs): + super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) + self.fields['vrf'].empty_label = 'Global' class IPAddressFromCSVForm(forms.ModelForm): @@ -602,14 +609,26 @@ 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, TenancyForm, 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 - fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant'] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", @@ -618,21 +637,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 +667,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)) @@ -697,7 +701,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'description'] + nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] def vlan_status_choices(): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 0bed7615e..980b17913 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -538,7 +538,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): verbose_name_plural = 'VLANs' def __str__(self): - return self.display_name + return self.display_name or super(VLAN, self).__str__() def get_absolute_url(self): return reverse('ipam:vlan', args=[self.pk]) @@ -565,7 +565,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @property def display_name(self): - return u'{} ({})'.format(self.vid, self.name) + if self.vid and self.name: + return u"{} ({})".format(self.vid, self.name) + return None def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 49f87d716..7e2ce017b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -76,6 +76,15 @@ IPADDRESS_LINK = """ {% endif %} """ +IPADDRESS_DEVICE = """ +{% if record.interface %} + {{ record.interface.device }} + ({{ record.interface.name }}) +{% else %} + — +{% endif %} +""" + VRF_LINK = """ {% if record.vrf %} {{ record.vrf }} @@ -281,12 +290,14 @@ class IPAddressTable(BaseTable): status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK) - device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) - interface = tables.Column(orderable=False) + nat_inside = tables.LinkColumn( + 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' + ) + device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False) class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 87d2636d8..255e449a0 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, @@ -536,7 +533,7 @@ def prefix_ipaddresses(request, pk): # class IPAddressListView(ObjectListView): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm table = tables.IPAddressTable @@ -587,8 +584,9 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): permission_required = 'ipam.add_ipaddress' - form = forms.IPAddressBulkAddForm - model_form = forms.IPAddressForm + pattern_form = forms.IPAddressPatternForm + model_form = forms.IPAddressBulkAddForm + pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' default_return_url = 'ipam:ipaddress_list' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 861c618cd..4eee4b485 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.0.1' +VERSION = '2.0.2' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 8e42086bc..79ffa651e 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -36,7 +36,7 @@ SEARCH_TYPES = { 'url': 'circuits:provider_list', }, 'circuit': { - 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'), + 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), 'filter': CircuitFilter, 'table': CircuitSearchTable, 'url': 'circuits:circuit_list', diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 15e003d8f..ca9b2fd96 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -44,6 +44,7 @@ class SecretTable(BaseTable): class SecretSearchTable(SearchTable): + device = tables.LinkColumn() class Meta(SearchTable.Meta): model = Secret diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 6b5e4497d..7cff002ae 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -8,12 +8,18 @@ {% render_field form.provider %} {% render_field form.cid %} {% render_field form.type %} - {% render_field form.tenant %} {% render_field form.install_date %} {% render_field form.commit_rate %} {% render_field form.description %} +