From 188cfa08a976a9899734008f0b33d6edf51d3bd6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 9 May 2017 22:48:14 -0400 Subject: [PATCH 01/28] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 861c618cd8..b625f653c2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.0.1' +VERSION = '2.0.2-dev' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None From 088f75ba0ce7b5e1f16fa5ac67937100043be4ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 May 2017 11:11:03 -0400 Subject: [PATCH 02/28] Added client_max_body_size to nginx config; removed statement disabling access logging --- docs/installation/web-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 6a058fddcc..44e7ee5334 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/; From 83688fceb7ec809e57d39d2621cb6ed387e59133 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 May 2017 11:23:54 -0400 Subject: [PATCH 03/28] Fixes #1158: Exception thrown when creating a device component with an invalid name --- netbox/dcim/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 15755cbe9a..4ffe2d36b7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -105,6 +105,9 @@ def post(self, request, pk): 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))) From ed657210853711c0826511b122941aec628501be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 May 2017 13:16:33 -0400 Subject: [PATCH 04/28] Fixes #1160: Linkify secrets and tenants in global search results --- netbox/secrets/tables.py | 1 + netbox/tenancy/tables.py | 1 + 2 files changed, 2 insertions(+) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 15e003d8f3..ca9b2fd961 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -44,6 +44,7 @@ class Meta(BaseTable.Meta): class SecretSearchTable(SearchTable): + device = tables.LinkColumn() class Meta(SearchTable.Meta): model = Secret diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index bacb4c12fd..56cf1c02a0 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -44,6 +44,7 @@ class Meta(BaseTable.Meta): class TenantSearchTable(SearchTable): + name = tables.LinkColumn() class Meta(SearchTable.Meta): model = Tenant From 9efa70a551cddf0e490f35b7e6ed8be1589896f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 May 2017 16:02:50 -0400 Subject: [PATCH 05/28] Fixes #1159: Only superusers can see "edit IP" buttons on the device interfaces list --- netbox/templates/dcim/inc/interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 9e2f9f8a55..86e4807101 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -134,7 +134,7 @@ {{ ip.get_status_display }} - {% if perms.ipam.edit_ipaddress %} + {% if perms.ipam.change_ipaddress %} From 0f97478b553c477ac95d58b2dfe489b2aec8a08e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 May 2017 22:22:49 -0400 Subject: [PATCH 06/28] Fixes #1161: Fix "add another" behavior when creating an API token --- netbox/users/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/users/views.py b/netbox/users/views.py index 03e9137423..d88217f40b 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -216,10 +216,13 @@ def post(self, request, pk=None): token.user = request.user token.save() - msg = "Token updated" if pk else "New token created" + msg = "Modified token {}".format(token) if pk else "Created token {}".format(token) messages.success(request, msg) - return redirect('user:token_list') + if '_addanother' in request.POST: + return redirect(request.path) + else: + return redirect('user:token_list') class TokenDeleteView(LoginRequiredMixin, View): From 58bb029666a1d5206eb8020186e7da7874b64870 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 16:24:57 -0400 Subject: [PATCH 07/28] Closes #1167: Introduced ChainedModelChoiceFields --- netbox/circuits/forms.py | 76 +++------- netbox/dcim/forms.py | 293 ++++++++++++-------------------------- netbox/ipam/forms.py | 229 +++++++++++++++-------------- netbox/ipam/views.py | 5 +- netbox/utilities/forms.py | 45 ++++++ 5 files changed, 275 insertions(+), 373 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 384fc053d7..6f3f9a8a7f 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -5,8 +5,8 @@ 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 Meta: 'term_side': forms.HiddenInput(), } - def __init__(self, *args, **kwargs): - - 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 = [] + def __init__(self, instance=None, initial=None, *args, **kwargs): + + # Initialize helper selectors + if instance and instance.interface is not None: + initial['rack'] = instance.interface.device.rack + initial['device'] = instance.interface.device + + super(CircuitTerminationForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) + + # 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 79fb865df3..896e91f6a1 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -11,8 +11,9 @@ 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 Meta: # 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 Meta: '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 Meta: # 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,18 +589,17 @@ class Meta: } 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 - if self.instance.pk: + super(DeviceForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) - # Initialize helper selections - self.initial['site'] = self.instance.site - self.initial['manufacturer'] = self.instance.device_type.manufacturer + if self.instance.pk: # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: @@ -607,14 +623,6 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): }) 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 @@ def clean(self): 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 @@ def __init__(self, *args, **kwargs): 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 Meta: '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 @@ def clean(self): 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 @@ def __init__(self, *args, **kwargs): 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 Meta: '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 @@ def __init__(self, *args, **kwargs): # 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 @@ def __init__(self, device_a, *args, **kwargs): (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 439f1bf9e4..de610edf2d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,8 +5,8 @@ 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 Meta: # 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 @@ def __init__(self, *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 +211,6 @@ def clean(self): 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,45 +400,24 @@ 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): - self.fields['vrf'].empty_label = 'Global' + # 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 - # 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 = [] + # 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 - # 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 = [] + super(IPAddressForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) - # 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 = [] + self.fields['vrf'].empty_label = 'Global' # Initialize primary_for_device if IP address is already assigned if self.instance.interface is not None: @@ -398,38 +428,6 @@ def __init__(self, *args, **kwargs): ): 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 Meta: '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 @@ def clean(self): 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 87d2636d88..612a89922c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -2,15 +2,12 @@ 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 d14c56e7b7..75ac82adef 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -331,6 +331,25 @@ def to_python(self, value): 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 @@ def __init__(self, *args, **kwargs): 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. From 45bb7eec0b00b6420193b85a08a1ca6de71c020c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 17:20:50 -0400 Subject: [PATCH 08/28] Corrected queryset filter when parent_field is None --- netbox/utilities/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 75ac82adef..a4aeae2dff 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -443,8 +443,8 @@ def __init__(self, *args, **kwargs): 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) + if self.is_bound: + filters_dict[db_field] = self.data.get(parent_field) or None elif self.initial.get(parent_field): filters_dict[db_field] = self.initial[parent_field] else: From 473b35f9a3268b538f6ec6c3e02421b87b68b3c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 17:35:20 -0400 Subject: [PATCH 09/28] Added tenant_group/tenant form section to all objects with tenancy --- netbox/circuits/forms.py | 8 +++-- netbox/dcim/forms.py | 19 +++++----- netbox/ipam/forms.py | 24 +++++++------ netbox/templates/circuits/circuit_edit.html | 8 ++++- netbox/templates/dcim/device_edit.html | 8 ++++- netbox/templates/dcim/rack_edit.html | 15 ++++++-- netbox/templates/dcim/site_edit.html | 8 ++++- netbox/templates/ipam/ipaddress_edit.html | 10 ++++-- netbox/templates/ipam/prefix_edit.html | 12 +++++-- netbox/templates/ipam/vlan_edit.html | 8 ++++- netbox/templates/ipam/vrf_edit.html | 8 ++++- netbox/tenancy/forms.py | 40 +++++++++++++++++++-- 12 files changed, 133 insertions(+), 35 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 6f3f9a8a7f..f3894c02e0 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,6 +3,7 @@ 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, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, @@ -83,12 +84,15 @@ class Meta: # 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", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 896e91f6a1..07b3f9d749 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -8,6 +8,7 @@ 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, @@ -81,7 +82,7 @@ class Meta: # Sites # -class SiteForm(BootstrapMixin, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() @@ -89,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}), @@ -185,7 +186,7 @@ class Meta: # Racks # -class RackForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): +class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): group = ChainedModelChoiceField( queryset=RackGroup.objects.all(), chains={'site': 'site'}, @@ -199,8 +200,8 @@ class RackForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', - 'comments', + '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", @@ -534,7 +535,7 @@ class Meta: # Devices # -class DeviceForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.Select( @@ -580,8 +581,8 @@ class DeviceForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): 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", diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index de610edf2d..108fd0e2f7 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -3,10 +3,11 @@ 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, ChainedFieldsMixin, ChainedModelChoiceField, - 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 @@ # 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,7 +164,7 @@ class Meta: # Prefixes # -class PrefixForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): +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'} @@ -177,7 +178,7 @@ class PrefixForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): 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) @@ -306,7 +307,7 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # IP addresses # -class IPAddressForm(BootstrapMixin, ChainedFieldsMixin, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): interface_site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -399,7 +400,10 @@ class IPAddressForm(BootstrapMixin, ChainedFieldsMixin, ReturnURLForm, CustomFie class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside'] + fields = [ + 'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group', + 'tenant', + ] def __init__(self, instance=None, initial=None, *args, **kwargs): @@ -600,7 +604,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.Select( @@ -619,7 +623,7 @@ class VLANForm(BootstrapMixin, ChainedFieldsMixin, CustomFieldForm): 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)", diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 6b5e4497da..7cff002ae4 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 %} +
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 522d39d1d0..7bfec54692 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,7 +7,6 @@
{% render_field form.name %} {% render_field form.device_role %} - {% render_field form.tenant %}
@@ -63,6 +62,13 @@ {% endif %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index dd4f610c34..d7a6f0dfb8 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -6,11 +6,22 @@
Rack
{% render_field form.site %} - {% render_field form.group %} {% render_field form.name %} {% render_field form.facility_id %} - {% render_field form.tenant %} + {% render_field form.group %} {% render_field form.role %} +
+
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
+
+
Dimensions
+
{% render_field form.type %} {% render_field form.width %} {% render_field form.u_height %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 98f16ad252..a1c13075a5 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -8,11 +8,17 @@ {% render_field form.name %} {% render_field form.slug %} {% render_field form.region %} - {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
Contact Info
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d5c5ad1af2..d7aef0fe44 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -13,12 +13,18 @@
IP Address
{% render_field form.address %} - {% render_field form.vrf %} - {% render_field form.tenant %} {% render_field form.status %} + {% render_field form.vrf %} {% render_field form.description %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
Interface Assignment diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index ca5de43b36..089335c02a 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -6,14 +6,20 @@
Prefix
{% render_field form.prefix %} + {% render_field form.status %} {% render_field form.vrf %} - {% render_field form.tenant %} {% render_field form.site %} {% render_field form.vlan %} - {% render_field form.status %} {% render_field form.role %} - {% render_field form.is_pool %} {% render_field form.description %} + {% render_field form.is_pool %} +
+
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %}
{% if form.custom_fields %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 10c633aa53..1d468ceb94 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -9,12 +9,18 @@ {% render_field form.group %} {% render_field form.vid %} {% render_field form.name %} - {% render_field form.tenant %} {% render_field form.status %} {% render_field form.role %} {% render_field form.description %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index fc4b438e61..63052129cb 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -7,11 +7,17 @@
{% render_field form.name %} {% render_field form.rd %} - {% render_field form.tenant %} {% render_field form.enforce_unique %} {% render_field form.description %}
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 485f2f34b1..fc99c5175f 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,8 +2,10 @@ from django.db.models import Count from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField - +from utilities.forms import ( + APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, + FilterChoiceField, SlugField, +) from .models import Tenant, TenantGroup @@ -61,3 +63,37 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_option=(0, 'None') ) + + +# +# Tenancy form extension +# + +class TenancyForm(ChainedFieldsMixin, forms.Form): + tenant_group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'tenant', 'nullable': 'true'} + ) + ) + tenant = ChainedModelChoiceField( + queryset=Tenant.objects.all(), + chains={'group': 'tenant_group'}, + required=False, + widget=APISelect( + api_url='/api/tenancy/tenants/?group_id={{tenant_group}}' + ) + ) + + def __init__(self, *args, **kwargs): + + # Initialize helper selector + instance = kwargs.get('instance') + if instance and instance.tenant is not None: + try: + kwargs['initial']['tenant_group'] = instance.tenant.group + except KeyError: + kwargs['initial'] = {'tenant_group': instance.tenant.group} + + super(TenancyForm, self).__init__(*args, **kwargs) From ed80bfaf02d1cf6c3b47a2f6a2ed79f6b83770b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 17:52:23 -0400 Subject: [PATCH 10/28] Fixed selector initializations for TenancyForms --- netbox/circuits/forms.py | 7 +++++-- netbox/dcim/forms.py | 9 ++++++--- netbox/ipam/forms.py | 11 ++++++----- netbox/tenancy/forms.py | 7 +++---- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index f3894c02e0..f81abff04c 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -219,14 +219,17 @@ class Meta: 'term_side': forms.HiddenInput(), } - def __init__(self, instance=None, initial=None, *args, **kwargs): + 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__(instance=instance, initial=initial, *args, **kwargs) + super(CircuitTerminationForm, self).__init__(*args, **kwargs) # Mark connected interfaces as disabled self.fields['interface'].choices = [ diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 07b3f9d749..429e1d7aa2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -592,13 +592,16 @@ class Meta: 'face': forms.Select(attrs={'filter-for': 'position'}), } - def __init__(self, instance=None, initial=None, *args, **kwargs): + def __init__(self, *args, **kwargs): - # Initialize helper selections + # Initialize helper selectors + instance = kwargs.get('instance') if instance and instance.device_type is not None: + initial = kwargs.get('initial', {}) initial['manufacturer'] = instance.device_type.manufacturer + kwargs['initial'] = initial - super(DeviceForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) + super(DeviceForm, self).__init__(*args, **kwargs) if self.instance.pk: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 108fd0e2f7..3b81f7d89b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -405,21 +405,22 @@ class Meta: 'tenant', ] - def __init__(self, instance=None, initial=None, *args, **kwargs): + def __init__(self, *args, **kwargs): - # Initialize interface selectors + # 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 - - # 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 + kwargs['initial'] = initial - super(IPAddressForm, self).__init__(instance=instance, initial=initial, *args, **kwargs) + super(IPAddressForm, self).__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index fc99c5175f..e9a39d237e 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -91,9 +91,8 @@ def __init__(self, *args, **kwargs): # Initialize helper selector instance = kwargs.get('instance') if instance and instance.tenant is not None: - try: - kwargs['initial']['tenant_group'] = instance.tenant.group - except KeyError: - kwargs['initial'] = {'tenant_group': instance.tenant.group} + initial = kwargs.get('initial', {}) + initial['tenant_group'] = instance.tenant.group + kwargs['initial'] = initial super(TenancyForm, self).__init__(*args, **kwargs) From e23904568831567ff4a4f19dcd8ecb2d9d66324b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 17:54:43 -0400 Subject: [PATCH 11/28] PEP8 fixes --- netbox/dcim/forms.py | 6 +++--- netbox/utilities/forms.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 429e1d7aa2..09ea6af8c8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1400,7 +1400,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor ) rack_b = ChainedModelChoiceField( queryset=Rack.objects.all(), - chains = {'site': 'site_b'}, + chains={'site': 'site_b'}, label='Rack', required=False, widget=APISelect( @@ -1410,7 +1410,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor ) device_b = ChainedModelChoiceField( queryset=Device.objects.all(), - chains = {'site': 'site_b', 'rack': 'rack_b'}, + chains={'site': 'site_b', 'rack': 'rack_b'}, label='Device', required=False, widget=APISelect( @@ -1432,7 +1432,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' ), - chains = {'device': 'device_b'}, + chains={'device': 'device_b'}, label='Interface', widget=APISelect( api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a4aeae2dff..8afe8b58e9 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -335,14 +335,14 @@ 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): From 008ed3455373a92d5e2fa99612e5a36f4958de70 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 May 2017 23:30:23 -0400 Subject: [PATCH 12/28] Fixes #1168: Total count of obejcts missing from list view paginator --- netbox/templates/table_paginator.html | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/templates/table_paginator.html b/netbox/templates/table_paginator.html index 51965d0236..90bd41473b 100644 --- a/netbox/templates/table_paginator.html +++ b/netbox/templates/table_paginator.html @@ -7,7 +7,7 @@ {% endif %}
- Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }} - {% if total == 1 %} - {{ table.data.verbose_name }} - {% else %} - {{ table.data.verbose_name_plural }} - {% endif %} + {% with table.page.paginator.count as total %} + Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }} + {% if total == 1 %} + {{ table.data.verbose_name }} + {% else %} + {{ table.data.verbose_name_plural }} + {% endif %} + {% endwith %}
From a870a3b9185d35580818d7663618d9917ebf4caf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 12:00:26 -0400 Subject: [PATCH 13/28] Fixes #1166: Re-implemented bulk IP address creation --- netbox/ipam/forms.py | 14 ++-- netbox/ipam/views.py | 5 +- netbox/templates/ipam/ipaddress_bulk_add.html | 14 ++-- netbox/utilities/views.py | 68 +++++++++++-------- 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3b81f7d89b..52ad8c26fa 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -471,15 +471,19 @@ def save(self, *args, **kwargs): 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, CustomFieldForm): class Meta: model = IPAddress - fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description'] + fields = ['address', 'status', 'vrf', 'tenant', 'description'] + + def __init__(self, *args, **kwargs): + super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) + self.fields['vrf'].empty_label = 'Global' class IPAddressFromCSVForm(forms.ModelForm): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 612a89922c..149db5f872 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -584,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/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index d53f73bd54..6dcfaf24ec 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -12,18 +12,18 @@
IP Addresses
- {% render_field form.address_pattern %} - {% render_field form.vrf %} - {% render_field form.tenant %} - {% render_field form.status %} - {% render_field form.description %} + {% render_field pattern_form.pattern %} + {% render_field model_form.status %} + {% render_field model_form.vrf %} + {% render_field model_form.tenant %} + {% render_field model_form.description %}
- {% if form.custom_fields %} + {% if model_form.custom_fields %}
Custom Fields
- {% render_custom_fields form %} + {% render_custom_fields model_form %}
{% endif %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a910559648..861977c313 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -290,66 +290,78 @@ class BulkAddView(View): """ Create new objects in bulk. - form: Form class + pattern_form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects template_name: The name of the template default_return_url: Name of the URL to which the user is redirected after creating the objects """ - form = None + pattern_form = None model_form = None + pattern_target = '' template_name = None default_return_url = 'home' def get(self, request): - form = self.form() + pattern_form = self.pattern_form() + model_form = self.model_form() return render(request, self.template_name, { 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'form': form, + 'pattern_form': pattern_form, + 'model_form': model_form, 'return_url': reverse(self.default_return_url), }) def post(self, request): model = self.model_form._meta.model - form = self.form(request.POST) - if form.is_valid(): + pattern_form = self.pattern_form(request.POST) + model_form = self.model_form(request.POST) - # Read the pattern field and target from the form's pattern_map - pattern_field, pattern_target = form.pattern_map - pattern = form.cleaned_data[pattern_field] - model_form_data = form.cleaned_data + if pattern_form.is_valid(): + pattern = pattern_form.cleaned_data['pattern'] new_objs = [] + try: with transaction.atomic(): - # Validate and save each object individually + + # Create objects from the expanded. Abort the transaction on the first validation error. for value in pattern: - model_form_data[pattern_target] = value - model_form = self.model_form(model_form_data) + + # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable + # copy of the POST QueryDict so that we can update the target field value. + model_form = self.model_form(request.POST.copy()) + model_form.data[self.pattern_target] = value + + # Validate each new object independently. if model_form.is_valid(): obj = model_form.save() new_objs.append(obj) else: - for error in model_form.errors.as_data().values(): - form.add_error(None, error) - # Abort the creation of all objects if errors exist - if form.errors: - raise ValidationError("Validation of one or more model forms failed.") - except ValidationError: - pass + # Copy any errors on the pattern target field to the pattern form. + errors = model_form.errors.as_data() + if errors.get(self.pattern_target): + pattern_form.add_error('pattern', errors[self.pattern_target]) + # Raise an IntegrityError to break the for loop and abort the transaction. + raise IntegrityError() + + # If we make it to this point, validation has succeeded on all new objects. + msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) + messages.success(request, msg) + UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg) - if not form.errors: - msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) - messages.success(request, msg) - UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg) - if '_addanother' in request.POST: - return redirect(request.path) - return redirect(self.default_return_url) + if '_addanother' in request.POST: + return redirect(request.path) + return redirect(self.default_return_url) + + except IntegrityError: + pass return render(request, self.template_name, { - 'form': form, + 'pattern_form': pattern_form, + 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': reverse(self.default_return_url), }) From f9b2c59974142048a65d29d3cc8fba84e520edf5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 12:04:06 -0400 Subject: [PATCH 14/28] Moved tenancy to separate panel on bulk IP creation form --- netbox/ipam/forms.py | 4 ++-- netbox/templates/ipam/ipaddress_bulk_add.html | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 52ad8c26fa..6e5489084e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -475,11 +475,11 @@ class IPAddressPatternForm(BootstrapMixin, forms.Form): pattern = ExpandableIPAddressField(label='Address pattern') -class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm): +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress - fields = ['address', 'status', 'vrf', 'tenant', 'description'] + fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant'] def __init__(self, *args, **kwargs): super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 6dcfaf24ec..668f495ebc 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -15,10 +15,16 @@ {% render_field pattern_form.pattern %} {% render_field model_form.status %} {% render_field model_form.vrf %} - {% render_field model_form.tenant %} {% render_field model_form.description %} +
+
Tenancy
+
+ {% render_field model_form.tenant_group %} + {% render_field model_form.tenant %} +
+
{% if model_form.custom_fields %}
Custom Fields
From d87acc97c361980541eadab81706ecc9590a3fb9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 12:06:37 -0400 Subject: [PATCH 15/28] Fixes #1171: Allow removing site assignment when bulk editing VLANs --- netbox/ipam/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6e5489084e..ec596d612f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -701,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(): From 8bae804508d67cece1de71fa92d0ac4fafedcae9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 12:12:47 -0400 Subject: [PATCH 16/28] Closes #1170: Include A and Z sites for circuits in global search results --- netbox/circuits/tables.py | 8 +++++++- netbox/netbox/views.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 07e2c44776..3cda30ccc5 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/netbox/views.py b/netbox/netbox/views.py index 8e42086bcc..79ffa651ea 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -36,7 +36,7 @@ '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', From 66ae62fb914dc821cbcecf1573d17e49df0256a5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 14:19:37 -0400 Subject: [PATCH 17/28] Closes #1172: Linkify racks in side-by-side elevations view --- netbox/templates/dcim/rack_elevation_list.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index ab6691f403..00496a0d0e 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -14,7 +14,7 @@

{% block title %}Rack Elevations{% endblock %}

{% for rack in page %}
-

{{ rack.name }}

+

{{ rack.name }}

{% if face_id %} {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %} @@ -23,7 +23,7 @@

{{ rack.name }}

{% endif %}
-

{{ rack.name }}

+

{{ rack.name }}

{% endfor %} From 73bf4f45c3a0ac186556fab55e48ed0b96555e83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 15:31:34 -0400 Subject: [PATCH 18/28] Adapted model get_display_name() to better handle unsaved instances --- netbox/dcim/models.py | 17 ++++++++--------- netbox/ipam/models.py | 6 ++++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f991d57962..fa14074876 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -410,7 +410,7 @@ class Meta: ] 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 @@ def units(self): 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): """ @@ -983,7 +985,7 @@ class Meta: 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 +1104,9 @@ def to_csv(self): 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/ipam/models.py b/netbox/ipam/models.py index 0bed7615eb..980b179136 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -538,7 +538,7 @@ class Meta: 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 @@ def to_csv(self): @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] From 67282882faae39e21fac626284a5ed3be841911e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 15:55:18 -0400 Subject: [PATCH 19/28] Fixed RelatedObjectDoesNotExist error when trying to create a new device --- netbox/dcim/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 09ea6af8c8..cdfc8d0a88 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -596,7 +596,8 @@ def __init__(self, *args, **kwargs): # Initialize helper selectors instance = kwargs.get('instance') - if instance and instance.device_type is not None: + # 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 From c9d3cf301ebd7fb89dd04003e7ac0348ad7db853 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 16:10:18 -0400 Subject: [PATCH 20/28] Fixes #1173: Tweak interface manager to fall back to naive ordering --- netbox/dcim/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index fa14074876..59d7a0ef22 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -812,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: @@ -830,16 +830,17 @@ def order_naturally(self, method=IFACE_ORDERING_POSITION): 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), From 7f23cb9bf5a696ef05beda0dca0df54cba5913de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 22:11:20 -0400 Subject: [PATCH 21/28] Closes #1122: Include NAT inside IPs in IP address list --- netbox/ipam/tables.py | 17 ++++++++++++++--- netbox/ipam/views.py | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 49f87d7168..7e2ce017b4 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -76,6 +76,15 @@ {% 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 149db5f872..255e449a04 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -533,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 From ab30ba1e1b11a163f260afb3e611aeb321bbc05a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 22:20:21 -0400 Subject: [PATCH 22/28] Fixed dynamic selection of device type filter on devices list --- netbox/templates/dcim/device_list.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 7b0984a698..65d9e8596f 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -41,7 +41,7 @@

Devices

url: api_url, dataType: 'json', success: function (response, status) { - $.each(response, function (index, device_type) { + $.each(response["results"], function (index, device_type) { var option = $("").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")"); model_list.append(option); }); From d11dfe2ced7a73b567c2b807561b466ce8c0576d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 May 2017 22:41:27 -0400 Subject: [PATCH 23/28] Closes #1137: Allow filtering devices list by rack --- netbox/dcim/forms.py | 4 ++ netbox/templates/dcim/device_list.html | 69 ++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index cdfc8d0a88..414e543060 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -814,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', diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 65d9e8596f..211317bcf9 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -31,24 +31,83 @@

Devices

{% block javascript %} {% endblock %} From ca1725b98c25a6c2c0fb195511e08fe8c43836e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 May 2017 11:03:11 -0400 Subject: [PATCH 24/28] Fixes #1178: Fix API representation of connected interface's form factor --- netbox/dcim/api/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a9c52e3fdf..4f02db8470 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -608,6 +608,7 @@ def get_connected_interface(self, obj): 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 From 3be6e5b0151d61c88e3f9ef310d234356f87ffa6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 May 2017 12:56:16 -0400 Subject: [PATCH 25/28] Closes #1179: Adjust topology map text color based on node background --- netbox/extras/models.py | 7 +++++-- netbox/utilities/utils.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 62eb07d1a0..47dd912436 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -13,6 +13,8 @@ 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 @@ -336,8 +338,9 @@ def render(self, img_format='png'): 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): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 8aaded4104..6a37eb3439 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -24,3 +24,15 @@ def csv_format(data): csv.append(u'{}'.format(value)) return u','.join(csv) + + +def foreground_color(bg_color): + """ + Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. + """ + bg_color = bg_color.strip('#') + r, g, b = [int(bg_color[c:c+2], 16) for c in (0, 2, 4)] + if r * 0.299 + g * 0.587 + b * 0.114 > 186: + return '000000' + else: + return 'ffffff' From a49521d6838c0ea47f8f1b8b908ee8b028fbc6cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 May 2017 13:11:20 -0400 Subject: [PATCH 26/28] #1177: Render planned connections as dashed lines on topology maps --- netbox/extras/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 47dd912436..66d44d8a5a 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -318,7 +318,7 @@ def device_sets(self): 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() @@ -360,7 +360,8 @@ def render(self, img_format='png'): 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): From aa6ca21a34f57b8ce2f06d9b2e5101699ce33623 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 May 2017 13:18:49 -0400 Subject: [PATCH 27/28] PEP8 fix --- netbox/utilities/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 6a37eb3439..98400bccd2 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -31,7 +31,7 @@ def foreground_color(bg_color): Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. """ bg_color = bg_color.strip('#') - r, g, b = [int(bg_color[c:c+2], 16) for c in (0, 2, 4)] + r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)] if r * 0.299 + g * 0.587 + b * 0.114 > 186: return '000000' else: From a1c12cfd779059a86494e5f8d2fed0cabed99256 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 15 May 2017 13:19:18 -0400 Subject: [PATCH 28/28] Release v2.0.2 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b625f653c2..4eee4b4853 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.0.2-dev' +VERSION = '2.0.2' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None