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 e3c3025f9e1..158848aa57f 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 26d38d56a5e..8acad4bb959 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 1f5d50c4d83..e051e33e5be 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 a5b0a7e3ca0..c67921e3e0e 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 5eb3bda61e2..00194d4e8b9 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 647ecb723fd..3e403e676cc 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 6ba49d02ca8..1817cd9a993 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 16b33962cfe..d697de755b4 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 bd6bcc81f14..5aeec0eb1cb 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 c79cf895556..4fe914f647d 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 4951942c38b..2535b672dee 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 ebb86731c08..8d7d29b965f 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 e952777045a..cb3b1ff3b52 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 9b681e4a747..0dc393cfb3a 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 8f6b3ada836..5204e6a0e9a 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 b0c35a0e95f..99fb76a7d35 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 4ec2f0f33eb..1fe60707aed 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 db9c1de4dbe..ba7e11bbf6f 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 530456a5642..10dff7f3aab 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 1fe60707aed..aa247869865 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 05585348fa2..b5eb8874ed7 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 0498edd4655..3938581fcd6 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 10f776ea310..9a5225d5653 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 cb3b1ff3b52..01ad71ed080 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 8d7d29b965f..32f04c223b1 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 2b29899418f..b3c67e9e2c2 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 3938581fcd6..2ace86d77c6 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 e56a12ac090..c3ef82704e8 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 e051e33e5be..ebe10942a3a 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 0dc393cfb3a..b0b5beae32c 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 73fddbb0a0f..4d61b4acb58 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 b0b5beae32c..ff402b6c3a9 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 32b15670c35..1db2dcefa81 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 21de1014ead..b5076527180 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 bdaec4fd812..9b30586f2dc 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 25475eec852..18e7ff7e57a 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 c3ef82704e8..f3d70edd4e6 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 ebe10942a3a..98c075cd334 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 d50b354877d..2b1f403e7d6 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 01ad71ed080..0349396fa7f 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 a157745692c..bd10ad21666 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 ff402b6c3a9..5bf2c337b8d 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 00000000000..612534d980c --- /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 dd811fb54e3..1857afcc2c1 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -104,7 +104,7 @@ -