From b4a842d9dac1455ea1fb73728fe801709d124707 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 78ed85943bc6072d411d80720473890183eea335 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 935da0d51f0ca6cda8fd9c5fbfece34868bca4c7 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 bb653e733cb8b70fd3eabca8867954792df03844 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 e58d1ac87e7629f7b77a58d5befa60c04b270298 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 d5ecfe7bef6ffb4e6d3a9ee56c0179edee9d2332 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 7ac27b59c645d1edc6b6dc0ecfce56ba9a8d4304 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 5262156e1a1679adf8c6e971a92b1e291a012dd7 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 9ea8dca4e3e706cf381137b90014922328e8ff32 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 7341ae087cf57672e21662b4dba9a22f83275bde 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 53998e0fff394e3410a932afb16fe500468a89ed 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 6b50755a5a1df04055ca6b21ecc42773cdcfb54d 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 21fe7c57d88e102c4ff25f521d32b6ce70868873 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 4e8fc03c2b6e5ed3b2d0689a480539a940bf13df 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 3edf90714af47a8ff2dcf6fcc8824f6c638f3eb4 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 16f222b0ab114475649a448a0dad61e6375aaf57 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 ffc2c564b8ac3d1b9471eed9d7b199b4160f7cc9 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 a5d2055c117f359b15d155cd5848dcd5beb5cdc9 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 2bb0e65aea595b92f03d957246b76a372edddf7f 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 @@ -