From 6bd814016bf91599e3f2858d046de980439df189 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Dec 2017 15:32:57 -0500 Subject: [PATCH 01/23] 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 e3c3025f9..158848aa5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.2.8' +VERSION = '2.2.9-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From d63c9965b4abc3c271129b19e91ee94697b10dd8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Dec 2017 12:08:22 -0500 Subject: [PATCH 02/23] Fixes #1765: Improved rendering of null options for model choice fields in filter forms --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 16 ++++++++-------- netbox/ipam/forms.py | 29 ++++++++++++++++------------- netbox/tenancy/forms.py | 2 +- netbox/utilities/filters.py | 2 +- netbox/utilities/forms.py | 31 ++++++++++++++++++------------- netbox/virtualization/forms.py | 16 ++++++++-------- 7 files changed, 53 insertions(+), 45 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 26d38d56a..8acad4bb9 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -174,7 +174,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1f5d50c4d..e051e33e5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -163,7 +163,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) @@ -359,17 +359,17 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): group_id = FilterChoiceField( queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')), label='Rack group', - null_option=(0, 'None') + null_label='-- None --' ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) role = FilterChoiceField( queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) @@ -411,7 +411,7 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form): group_id = FilterChoiceField( queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')), label='Rack group', - null_option=(0, 'None') + null_label='-- None --' ) @@ -1031,7 +1031,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_id = FilterChoiceField( queryset=Rack.objects.annotate(filter_count=Count('devices')), label='Rack', - null_option=(0, 'None'), + null_label='-- None --', ) role = FilterChoiceField( queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), @@ -1040,7 +1040,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', - null_option=(0, 'None'), + null_label='-- None --', ) manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') device_type_id = FilterChoiceField( @@ -1052,7 +1052,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): platform = FilterChoiceField( queryset=Platform.objects.annotate(filter_count=Count('devices')), to_field_name='slug', - null_option=(0, 'None'), + null_label='-- None --', ) status = forms.MultipleChoiceField(choices=device_status_choices, required=False) mac_address = forms.CharField(required=False, label='MAC address') diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index a5b0a7e3c..c67921e3e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -78,8 +78,11 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF q = forms.CharField(required=False, label='Search') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug', - null_option=(0, None)) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), + to_field_name='slug', + null_label='-- None --' + ) # @@ -368,23 +371,23 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd', label='VRF', - null_option=(0, 'Global') + null_label='-- Global --' ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) role = FilterChoiceField( queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -719,12 +722,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd', label='VRF', - null_option=(0, 'Global') + null_label='-- Global --' ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False) @@ -766,7 +769,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug', - null_option=(0, 'Global') + null_label='-- Global --' ) @@ -896,23 +899,23 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', - null_option=(0, 'Global') + null_label='-- Global --' ) group_id = FilterChoiceField( queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', - null_option=(0, 'None') + null_label='-- None --' ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) role = FilterChoiceField( queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5eb3bda61..00194d4e8 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -81,7 +81,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): group = FilterChoiceField( queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 647ecb723..3e403e676 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -42,7 +42,7 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): """ iterator = forms.models.ModelChoiceIterator - def __init__(self, null_value=0, null_label='None', *args, **kwargs): + def __init__(self, null_value=0, null_label='-- None --', *args, **kwargs): self.null_value = null_value self.null_label = null_label super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 6ba49d02c..1817cd9a9 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -407,11 +407,25 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceFieldMixin(object): - iterator = forms.models.ModelChoiceIterator +class FilterChoiceIterator(forms.models.ModelChoiceIterator): - def __init__(self, null_option=None, *args, **kwargs): - self.null_option = null_option + def __iter__(self): + # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string) + if self.field.null_label is not None: + yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label) + queryset = self.queryset.all() + # Can't use iterator() when queryset uses prefetch_related() + if not queryset._prefetch_related_lookups: + queryset = queryset.iterator() + for obj in queryset: + yield self.choice(obj) + + +class FilterChoiceFieldMixin(object): + iterator = FilterChoiceIterator + + def __init__(self, null_label=None, *args, **kwargs): + self.null_label = null_label if 'required' not in kwargs: kwargs['required'] = False if 'widget' not in kwargs: @@ -424,15 +438,6 @@ class FilterChoiceFieldMixin(object): return '{} ({})'.format(label, obj.filter_count) return label - def _get_choices(self): - if hasattr(self, '_choices'): - return self._choices - if self.null_option is not None: - return itertools.chain([self.null_option], self.iterator(self)) - return self.iterator(self) - - choices = property(_get_choices, forms.ChoiceField._set_choices) - class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): pass diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 16b33962c..d697de755 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -137,13 +137,13 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): group = FilterChoiceField( queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), to_field_name='slug', - null_option=(0, 'None'), + null_label='-- None --', required=False, ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('clusters')), to_field_name='slug', - null_option=(0, 'None'), + null_label='-- None --', required=False, ) @@ -338,12 +338,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) cluster_type = FilterChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) cluster_id = FilterChoiceField( queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), @@ -352,23 +352,23 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) role = FilterChoiceField( queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) platform = FilterChoiceField( queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')), to_field_name='slug', - null_option=(0, 'None') + null_label='-- None --' ) From 6afa7630992dc314beaab410716c04183e49c0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Fr=C3=BChwirth?= Date: Fri, 29 Dec 2017 13:21:32 +0100 Subject: [PATCH 03/23] Fixes #1802: Typo in ldap.md --- docs/installation/ldap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index bd6bcc81f..5aeec0eb1 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -81,7 +81,7 @@ AUTH_LDAP_USER_ATTR_MAP = { # User Groups for Permissions !!! info - When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. + When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. ```python from django_auth_ldap.config import LDAPSearch, GroupOfNamesType From 369f56207518ee8a6160664881f07b7c89929ac4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Jan 2018 15:19:27 -0500 Subject: [PATCH 04/23] Fixes #1621: Tweaked LLDP interface name evaluation logic --- netbox/templates/dcim/device_lldp_neighbors.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index c79cf8955..4fe914f64 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -58,9 +58,10 @@ $(document).ready(function() { // Glean configured hostnames/interfaces from the DOM var configured_device = row.children('td.configured_device').attr('data'); var configured_interface = row.children('td.configured_interface').attr('data'); + var configured_interface_short = null; if (configured_interface) { // Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1). - configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2"); + configured_interface_short = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2"); } // Clean up hostnames/interfaces learned via LLDP @@ -76,6 +77,8 @@ $(document).ready(function() { row.addClass('info'); } else if (configured_device == lldp_device && configured_interface == lldp_interface) { row.addClass('success'); + } else if (configured_device == lldp_device && configured_interface_short == lldp_interface) { + row.addClass('success'); } else { row.addClass('danger'); } From 61b712a19616e32c3570fc4afb130d8d70e37ece Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Jan 2018 15:31:48 -0500 Subject: [PATCH 05/23] Fixes #1807: Populate VRF from parent when creating a new prefix --- netbox/templates/ipam/prefix_prefixes.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/ipam/prefix_prefixes.html b/netbox/templates/ipam/prefix_prefixes.html index 4951942c3..2535b672d 100644 --- a/netbox/templates/ipam/prefix_prefixes.html +++ b/netbox/templates/ipam/prefix_prefixes.html @@ -6,7 +6,7 @@ {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
- {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} + {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
{% endblock %} From 4aa6f448d802254f8d68e06f1b117f1cb9dc0f39 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jan 2018 09:38:55 -0500 Subject: [PATCH 06/23] Fixes #1809: Populate tenant assignment from parent when creating a new prefix --- netbox/ipam/tables.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ebb86731c..8d7d29b96 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -48,13 +48,7 @@ PREFIX_LINK = """ {% else %} {% endif %} - {{ record.prefix }} - -""" - -PREFIX_LINK_BRIEF = """ - - {{ record.prefix }} + {{ record.prefix }} """ From 89ee93afd188fe23010666b236109d0feb1eac8e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Jan 2018 09:25:16 -0500 Subject: [PATCH 07/23] Closes #1824: Add virtual machine count to platforms list --- netbox/dcim/tables.py | 10 +++++++--- netbox/dcim/views.py | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e95277704..cb3b1ff3b 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -381,13 +381,17 @@ class PlatformTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') + vm_count = tables.Column(verbose_name='VMs') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + actions = tables.TemplateColumn( + template_code=PLATFORM_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = Platform - fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions') + fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b681e4a7..0dc393cfb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -754,7 +754,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class PlatformListView(ObjectListView): - queryset = Platform.objects.annotate(device_count=Count('devices')) + queryset = Platform.objects.annotate( + device_count=Count('devices', distinct=True), + vm_count=Count('virtual_machines', distinct=True) + ) table = tables.PlatformTable template_name = 'dcim/platform_list.html' From 89addf8962f70e4655adf28a5e14fd1f4995b5da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Jan 2018 10:30:26 -0500 Subject: [PATCH 08/23] Fixes #1818: InventoryItem API serializer no longer requires specifying a null value for items with no parent --- netbox/dcim/api/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8f6b3ada8..5204e6a0e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -733,6 +733,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): class WritableInventoryItemSerializer(ValidatedModelSerializer): + # Provide a default value to satisfy UniqueTogetherValidator + parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) class Meta: model = InventoryItem From 236932395657f238a392485768bf9bfd1bda76df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 19 Jan 2018 16:16:45 -0500 Subject: [PATCH 09/23] Evaluate device_id rather than pulling entire device (DB optimization) --- netbox/templates/dcim/inc/interface.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b0c35a0e9..99fb76a7d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -114,7 +114,7 @@ {% endif %} {% endif %} - + {% endif %} @@ -124,7 +124,7 @@ {% else %} - + {% endif %} From b620e6d79e876e41f84428894c5cad636c55f1e9 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 22 Jan 2018 10:43:19 -0500 Subject: [PATCH 10/23] added statement and exaple for using ForeignKey ID's in write actions --- docs/api/examples.md | 27 ++++++++++++++++++++++++++- docs/api/overview.md | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/api/examples.md b/docs/api/examples.md index 4ec2f0f33..1fe60707a 100644 --- a/docs/api/examples.md +++ b/docs/api/examples.md @@ -90,7 +90,7 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0 "id": 16, "name": "My New Site", "slug": "my-new-site", - "region": null, + "region": 5, "tenant": null, "facility": "", "asn": null, @@ -102,6 +102,31 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0 "comments": "" } ``` +Note that in this example we are creating a site bound to a region with the ID of 5. For write api actions (`POST`, `PUT`, and `PATCH`) the integer ID value is for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` action. + +### Creating a new site with an existing region + +Send a `POST` rquest as before to the site list endpoint, but this time include a value for an existing region. + +``` +$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}' +{ + "id": 16, + "name": "My New Site", + "slug": "my-new-site", + "region": 5, + "tenant": null, + "facility": "", + "asn": null, + "physical_address": "", + "shipping_address": "", + "contact_name": "", + "contact_phone": "", + "contact_email": "", + "comments": "" +} +``` +Note that in this example we are creating a site bound to a region with the ID of 5. For write api actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action. ### Modify an existing site diff --git a/docs/api/overview.md b/docs/api/overview.md index db9c1de4d..ba7e11bbf 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu } ``` -Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. +Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model. When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object. From a34917ec3243a4e230004810b233097d46af1d86 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jan 2018 16:04:19 -0500 Subject: [PATCH 11/23] Closes #1828: Added warning about media directory permissions --- docs/installation/netbox.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 530456a56..10dff7f3a 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -88,6 +88,13 @@ Resolving deltas: 100% (1495/1495), done. Checking connectivity... done. ``` +!!! warning + Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.) + + ``` + # chown -R netbox:netbox /opt/netbox/netbox/media/ + ``` + ## Install Python Packages Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) From 01d1b94738c1837564ce4e321eaa40bd9f6bd615 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 22 Jan 2018 16:26:51 -0500 Subject: [PATCH 12/23] fixed duplicate api docs example and grammar --- docs/api/examples.md | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/docs/api/examples.md b/docs/api/examples.md index 1fe60707a..aa2478698 100644 --- a/docs/api/examples.md +++ b/docs/api/examples.md @@ -82,10 +82,10 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6 ### Creating a new site -Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. +Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. This example includes one non required field, "region." ``` -$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}' +$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site", "region": 5}' { "id": 16, "name": "My New Site", @@ -102,31 +102,7 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0 "comments": "" } ``` -Note that in this example we are creating a site bound to a region with the ID of 5. For write api actions (`POST`, `PUT`, and `PATCH`) the integer ID value is for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` action. - -### Creating a new site with an existing region - -Send a `POST` rquest as before to the site list endpoint, but this time include a value for an existing region. - -``` -$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}' -{ - "id": 16, - "name": "My New Site", - "slug": "my-new-site", - "region": 5, - "tenant": null, - "facility": "", - "asn": null, - "physical_address": "", - "shipping_address": "", - "contact_name": "", - "contact_phone": "", - "contact_email": "", - "comments": "" -} -``` -Note that in this example we are creating a site bound to a region with the ID of 5. For write api actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action. +Note that in this example we are creating a site bound to a region with the ID of 5. For write API actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action. ### Modify an existing site From dcd46935fb41c3637b9f4d24be6c7a706d1ea122 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jan 2018 10:19:45 -0500 Subject: [PATCH 13/23] Closes #1835: Consistent position of previous/next rack buttons --- netbox/templates/dcim/rack.html | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 05585348f..b5eb8874e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -24,28 +24,20 @@
- {% if prev_rack %} - - - Previous Rack - - {% endif %} - {% if next_rack %} - - - Next Rack - - {% endif %} + + Previous Rack + + + Next Rack + {% if perms.dcim.change_rack %} - - Edit this rack + Edit this rack {% endif %} {% if perms.dcim.delete_rack %} - - Delete this rack + Delete this rack {% endif %}
From e8b01bec96a6b41e77a484113cc6fb84b9a4e7da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 11:18:37 -0500 Subject: [PATCH 14/23] Fixes #1845: Correct display of VMs in list with no role assigned --- netbox/virtualization/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 0498edd46..3938581fc 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -24,7 +24,7 @@ VIRTUALMACHINE_STATUS = """ """ VIRTUALMACHINE_ROLE = """ - +{% if record.role %}{% else %}—{% endif %} """ VIRTUALMACHINE_PRIMARY_IP = """ From 09ecf6588dc7fed80dee2e0769f88185f622af05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 11:57:21 -0500 Subject: [PATCH 15/23] Closes #1406: Display tenant description as title text in object tables --- netbox/circuits/tables.py | 3 ++- netbox/dcim/tables.py | 11 ++++++----- netbox/ipam/tables.py | 13 +++++++------ netbox/tenancy/tables.py | 8 ++++++++ netbox/virtualization/tables.py | 3 ++- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 10f776ea3..9a5225d56 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -4,6 +4,7 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor +from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider @@ -75,7 +76,7 @@ class CircuitTable(BaseTable): pk = ToggleColumn() cid = tables.LinkColumn(verbose_name='ID') provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side') termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side') diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index cb3b1ff3b..01ad71ed0 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor +from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -140,7 +141,7 @@ class SiteTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() region = tables.TemplateColumn(template_code=SITE_REGION_LINK) - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(BaseTable.Meta): model = Site @@ -207,7 +208,7 @@ class RackTable(BaseTable): name = tables.LinkColumn() site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) role = tables.TemplateColumn(RACK_ROLE) u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') @@ -231,7 +232,7 @@ class RackImportTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') facility_id = tables.Column(verbose_name='Facility ID') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') + tenant = tables.TemplateColumn(template_code=COL_TENANT) u_height = tables.Column(verbose_name='Height (U)') class Meta(BaseTable.Meta): @@ -402,7 +403,7 @@ class DeviceTable(BaseTable): pk = ToggleColumn() name = tables.TemplateColumn(template_code=DEVICE_LINK) status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') @@ -429,7 +430,7 @@ class DeviceDetailTable(DeviceTable): class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') + tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') position = tables.Column(verbose_name='Position') diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 8d7d29b96..32f04c223 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor +from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF @@ -131,9 +132,9 @@ VLANGROUP_ACTIONS = """ TENANT_LINK = """ {% if record.tenant %} - {{ record.tenant }} + {{ record.tenant }} {% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* + {{ record.vrf.tenant }}* {% else %} — {% endif %} @@ -148,7 +149,7 @@ class VRFTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() rd = tables.Column(verbose_name='RD') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(BaseTable.Meta): model = VRF @@ -239,7 +240,7 @@ class PrefixTable(BaseTable): prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - tenant = tables.TemplateColumn(TENANT_LINK) + tenant = tables.TemplateColumn(template_code=TENANT_LINK) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') role = tables.TemplateColumn(PREFIX_ROLE_LINK) @@ -268,7 +269,7 @@ class IPAddressTable(BaseTable): address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') status = tables.TemplateColumn(STATUS_LABEL) - tenant = tables.TemplateColumn(TENANT_LINK) + tenant = tables.TemplateColumn(template_code=TENANT_LINK) parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) interface = tables.Column(orderable=False) @@ -330,7 +331,7 @@ class VLANTable(BaseTable): vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) status = tables.TemplateColumn(STATUS_LABEL) role = tables.TemplateColumn(VLAN_ROLE_LINK) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2b2989941..b3c67e9e2 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -11,6 +11,14 @@ TENANTGROUP_ACTIONS = """ {% endif %} """ +COL_TENANT = """ +{% if record.tenant %} + {{ record.tenant }} +{% else %} + — +{% endif %} +""" + # # Tenant groups diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 3938581fc..2ace86d77 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -4,6 +4,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Interface +from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -97,7 +98,7 @@ class VirtualMachineTable(BaseTable): status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS) cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')]) role = tables.TemplateColumn(VIRTUALMACHINE_ROLE) - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(BaseTable.Meta): model = VirtualMachine From b0fdd9f3188bdfb2de8a7eff8869c2ae68933ad2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 12:11:20 -0500 Subject: [PATCH 16/23] Closes #1366: Enable searching for regions by name/slug --- netbox/dcim/filters.py | 13 +++++++++++++ netbox/dcim/forms.py | 5 +++++ netbox/dcim/views.py | 2 ++ netbox/templates/dcim/region_list.html | 5 ++++- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e56a12ac0..c3ef82704 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -22,6 +22,10 @@ from .models import ( class RegionFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -37,6 +41,15 @@ class RegionFilter(django_filters.FilterSet): model = Region fields = ['name', 'slug'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(slug__icontains=value) + ) + return queryset.filter(qs_filter) + class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e051e33e5..ebe10942a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -81,6 +81,11 @@ class RegionCSVForm(forms.ModelForm): } +class RegionFilterForm(BootstrapMixin, forms.Form): + model = Site + q = forms.CharField(required=False, label='Search') + + # # Sites # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0dc393cfb..b0b5beae3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -80,6 +80,8 @@ class BulkDisconnectView(View): class RegionListView(ObjectListView): queryset = Region.objects.annotate(site_count=Count('sites')) + filter = filters.RegionFilter + filter_form = forms.RegionFilterForm table = tables.RegionTable template_name = 'dcim/region_list.html' diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index 73fddbb0a..4d61b4acb 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -17,8 +17,11 @@

{% block title %}Regions{% endblock %}

-
+
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
+
+ {% include 'inc/search_panel.html' %} +
{% endblock %} From 84935999d3dd3461042ca13d70414b7914c7cf07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 13:06:43 -0500 Subject: [PATCH 17/23] Cleaned up InventoryItem add/edit/delete links and return URL --- netbox/dcim/views.py | 8 +++++++- netbox/templates/dcim/device_inventory.html | 13 +++++++------ netbox/templates/dcim/inc/inventoryitem.html | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b0b5beae3..ff402b6c3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,7 +23,7 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + ComponentEditView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -1826,8 +1826,14 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj + def get_return_url(self, request, obj): + return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) + class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem parent_field = 'device' + + def get_return_url(self, request, obj): + return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 32b15670c..1db2dcefa 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -64,13 +64,14 @@ {% endfor %} + {% if perms.dcim.add_inventoryitem %} + + {% endif %}
- {% if perms.dcim.add_inventoryitem %} - - - Add Inventory Item - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 21de1014e..b50765271 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -11,7 +11,7 @@ {% endif %} {% if perms.dcim.delete_inventoryitem %} - + {% endif %} From 4ed9187f70c53d502cff05f42eaae4ff611a43c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 13:39:33 -0500 Subject: [PATCH 18/23] Closes #1073: Include prefixes/IPs from all VRFs when viewing the children of a container prefix in the global table --- netbox/ipam/models.py | 16 ++++++++++++---- netbox/ipam/views.py | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bdaec4fd8..9b30586f2 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -283,15 +283,23 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): def get_child_prefixes(self): """ - Return all Prefixes within this Prefix and VRF. + Return all Prefixes within this Prefix and VRF. If this Prefix is a container in the global table, return child + Prefixes belonging to any VRF. """ - return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) + if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER: + return Prefix.objects.filter(prefix__net_contained=str(self.prefix)) + else: + return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) def get_child_ips(self): """ - Return all IPAddresses within this Prefix and VRF. + Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return + child IPAddresses belonging to any VRF. """ - return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf) + if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER: + return IPAddress.objects.filter(address__net_host_contained=str(self.prefix)) + else: + return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf) def get_available_prefixes(self): """ diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 25475eec8..18e7ff7e5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -491,11 +491,11 @@ class PrefixPrefixesView(View): prefix = get_object_or_404(Prefix.objects.all(), pk=pk) # Child prefixes table - child_prefixes = Prefix.objects.filter( - vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix) - ).select_related( + child_prefixes = prefix.get_child_prefixes().select_related( 'site', 'vlan', 'role', ).annotate_depth(limit=0) + + # Annotate available prefixes if child_prefixes: child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) From 8e3274533cca3a4b4b1dd63b340a7d3b636927e9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jan 2018 17:46:00 -0500 Subject: [PATCH 19/23] Closes #144: Implemented list and bulk edit/delete views for InventoryItems --- netbox/dcim/filters.py | 18 +++++++- netbox/dcim/forms.py | 44 +++++++++++++++++++ netbox/dcim/models.py | 15 +++++++ netbox/dcim/tables.py | 18 +++++++- netbox/dcim/urls.py | 6 ++- netbox/dcim/views.py | 35 ++++++++++++++- netbox/templates/dcim/inventoryitem_list.html | 23 ++++++++++ netbox/templates/inc/nav_menu.html | 12 ++++- 8 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/dcim/inventoryitem_list.html diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c3ef82704..f3d70edd4 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -613,6 +613,10 @@ class DeviceBayFilter(DeviceComponentFilterSet): class InventoryItemFilter(DeviceComponentFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', @@ -631,7 +635,19 @@ class InventoryItemFilter(DeviceComponentFilterSet): class Meta: model = InventoryItem - fields = ['name', 'part_id', 'serial', 'discovered'] + fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(part_id__icontains=value) | + Q(serial__iexact=value) | + Q(asset_tag__iexact=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) class ConsoleConnectionFilter(django_filters.FilterSet): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ebe10942a..98c075cd3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1928,3 +1928,47 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + + +class InventoryItemCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False, + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Invalid manufacturer.', + } + ) + + class Meta: + model = InventoryItem + fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + + +class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput) + manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) + part_id = forms.CharField(max_length=50, required=False, label='Part ID') + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = ['manufacturer', 'part_id', 'description'] + + +class InventoryItemFilterForm(BootstrapMixin, forms.Form): + model = InventoryItem + q = forms.CharField(required=False, label='Search') + manufacturer = FilterChoiceField( + queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')), + to_field_name='slug', + null_label='-- None --' + ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d50b35487..2b1f403e7 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1452,9 +1452,24 @@ class InventoryItem(models.Model): discovered = models.BooleanField(default=False, verbose_name='Discovered') description = models.CharField(max_length=100, blank=True) + csv_headers = [ + 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + ] + class Meta: ordering = ['device__id', 'parent__id', 'name'] unique_together = ['device', 'parent', 'name'] def __str__(self): return self.name + + def to_csv(self): + return csv_format([ + self.device.name or '{' + self.device.pk + '}', + self.name, + self.manufacturer.name if self.manufacturer else None, + self.part_id, + self.serial, + self.asset_tag, + self.description + ]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 01ad71ed0..0349396fa 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -7,8 +7,8 @@ from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, ) REGION_LINK = """ @@ -528,3 +528,17 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + + +# +# InventoryItems +# + +class InventoryItemTable(BaseTable): + pk = ToggleColumn() + device = tables.LinkColumn('dcim:device_inventory', args=[Accessor('device.pk')]) + manufacturer = tables.Column(accessor=Accessor('manufacturer.name'), verbose_name='Manufacturer') + + class Meta(BaseTable.Meta): + model = InventoryItem + fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..bd10ad216 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -195,9 +195,13 @@ urlpatterns = [ url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), # Inventory items - url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'), + url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), + url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), + url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), url(r'^inventory-items/(?P\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), # Console/power/interface connections url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ff402b6c3..5bf2c337b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,7 +23,7 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, + ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -1815,6 +1815,14 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # +class InventoryItemListView(ObjectListView): + queryset = InventoryItem.objects.select_related('device', 'manufacturer') + filter = filters.InventoryItemFilter + filter_form = forms.InventoryItemFilterForm + table = tables.InventoryItemTable + template_name = 'dcim/inventoryitem_list.html' + + class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem @@ -1837,3 +1845,28 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): def get_return_url(self, request, obj): return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) + + +class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_inventoryitem' + model_form = forms.InventoryItemCSVForm + table = tables.InventoryItemTable + default_return_url = 'dcim:inventoryitem_list' + + +class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_inventoryitem' + cls = InventoryItem + queryset = InventoryItem.objects.select_related('device', 'manufacturer') + filter = filters.InventoryItemFilter + table = tables.InventoryItemTable + form = forms.InventoryItemBulkEditForm + default_return_url = 'dcim:inventoryitem_list' + + +class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_inventoryitem' + cls = InventoryItem + queryset = InventoryItem.objects.select_related('device', 'manufacturer') + table = tables.InventoryItemTable + default_return_url = 'dcim:inventoryitem_list' diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html new file mode 100644 index 000000000..612534d98 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_list.html @@ -0,0 +1,23 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
+ {% if perms.dcim.add_devicetype %} + + + Import inventory items + + {% endif %} + {% include 'inc/export_button.html' with obj_type='inventory items' %} +
+

{% block title %}Inventory Items{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index dd811fb54..1857afcc2 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -104,7 +104,7 @@ -