From c7fa6108422c27526408682ecc25e340dc193065 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Aug 2018 09:19:33 -0400 Subject: [PATCH 01/11] 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 c2b313b56..360890656 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.2' +VERSION = '2.4.3-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 134370f48d81be0854136123e0ca64b0a02903e8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Aug 2018 14:58:16 -0400 Subject: [PATCH 02/11] Fixes #2335: API requires group field when creating/updating a rack --- netbox/dcim/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bc9179673..1cf6d753c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -120,7 +120,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() - group = NestedRackGroupSerializer(required=False, allow_null=True) + group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True) From a2ff21fab92126174b6dac6c31af40f296c5f513 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Aug 2018 15:09:30 -0400 Subject: [PATCH 03/11] Fixes #2334: TypeError raised when WritableNestedSerializer receives a non-integer value --- netbox/utilities/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index d753954aa..f7d4293a7 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -170,7 +170,9 @@ class WritableNestedSerializer(ModelSerializer): if data is None: return None try: - return self.Meta.model.objects.get(pk=data) + return self.Meta.model.objects.get(pk=int(data)) + except (TypeError, ValueError): + raise ValidationError("Primary key must be an integer") except ObjectDoesNotExist: raise ValidationError("Invalid ID") From 05059606c527350db8a6a3e9b3d16b46c9e2d90b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Aug 2018 15:22:26 -0400 Subject: [PATCH 04/11] Fixes #2336: Bulk deleting power outlets and console server ports from a device redirects to home page --- netbox/templates/dcim/device.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 62b54f361..c84bdf9a3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -573,7 +573,7 @@ {% endif %} {% if cs_ports or device.device_type.is_console_server %} {% if perms.dcim.delete_consoleserverport %} -
+ {% csrf_token %} {% endif %}
@@ -606,12 +606,12 @@ - {% endif %} {% if cs_ports and perms.dcim.delete_consoleserverport %} - {% endif %} @@ -631,7 +631,7 @@ {% endif %} {% if power_outlets or device.device_type.is_pdu %} {% if perms.dcim.delete_poweroutlet %} - + {% csrf_token %} {% endif %}
@@ -664,12 +664,12 @@ - {% endif %} {% if power_outlets and perms.dcim.delete_poweroutlet %} - {% endif %} From 6a56ffc6502cc3694bf303e7022064ab8104b79a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Aug 2018 16:16:49 -0400 Subject: [PATCH 05/11] Fixes #2337: Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError --- netbox/ipam/api/views.py | 10 ++++++---- netbox/ipam/tests/test_api.py | 8 ++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 30a987f74..e32688343 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -140,10 +140,11 @@ class PrefixViewSet(CustomFieldModelViewSet): available_prefixes.remove(allocated_prefix) # Initialize the serializer with a list or a single object depending on what was requested + context = {'request': request} if isinstance(request.data, list): - serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True) + serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) else: - serializer = serializers.PrefixSerializer(data=requested_prefixes[0]) + serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) # Create the new Prefix(es) if serializer.is_valid(): @@ -199,10 +200,11 @@ class PrefixViewSet(CustomFieldModelViewSet): requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None # Initialize the serializer with a list or a single object depending on what was requested + context = {'request': request} if isinstance(request.data, list): - serializer = serializers.IPAddressSerializer(data=requested_ips, many=True) + serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context) else: - serializer = serializers.IPAddressSerializer(data=requested_ips[0]) + serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context) # Create the new IP address(es) if serializer.is_valid(): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index f295bee29..0ff87d5cf 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -494,7 +494,8 @@ class PrefixTest(APITestCase): def test_create_single_available_prefix(self): - prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) + vrf = VRF.objects.create(name='Test VRF 1', rd='1234') + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) # Create four available prefixes with individual requests @@ -512,6 +513,7 @@ class PrefixTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['prefix'], prefixes_to_be_created[i]) + self.assertEqual(response.data['vrf']['id'], vrf.pk) self.assertEqual(response.data['description'], data['description']) # Try to create one more prefix @@ -562,7 +564,8 @@ class PrefixTest(APITestCase): def test_create_single_available_ip(self): - prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True) + vrf = VRF.objects.create(name='Test VRF 1', rd='1234') + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) # Create all four available IPs with individual requests @@ -572,6 +575,7 @@ class PrefixTest(APITestCase): } response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['vrf']['id'], vrf.pk) self.assertEqual(response.data['description'], data['description']) # Try to create one more IP From a6c78b99c4cbbd1223c8cb74f3af25831d51528b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 09:34:54 -0400 Subject: [PATCH 06/11] Fixes #2340: API requires manufacturer field when creating/updating an inventory item --- netbox/dcim/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1cf6d753c..0478932f7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -666,7 +666,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) - manufacturer = NestedManufacturerSerializer() + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) tags = TagListSerializerField(required=False) class Meta: From bf8eff11ea7416f112dfba8083e465dfb682e59b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 12:22:34 -0400 Subject: [PATCH 07/11] Closes #2333: Added search filters for ConfigContexts --- netbox/extras/api/views.py | 5 +- netbox/extras/filters.py | 91 ++++++++++++++++++- netbox/extras/forms.py | 39 +++++++- netbox/extras/tables.py | 7 +- netbox/extras/views.py | 4 +- netbox/templates/extras/configcontext.html | 14 +++ .../templates/extras/configcontext_list.html | 5 +- 7 files changed, 152 insertions(+), 13 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b278f9a7c..0fefa7ae6 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -138,8 +138,11 @@ class ImageAttachmentViewSet(ModelViewSet): # class ConfigContextViewSet(ModelViewSet): - queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants') + queryset = ConfigContext.objects.prefetch_related( + 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + ) serializer_class = serializers.ConfigContextSerializer + filter_class = filters.ConfigContextFilter # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 71c9314cd..3abd5b4cf 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -6,9 +6,10 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from taggit.models import Tag -from dcim.models import Site +from dcim.models import DeviceRole, Platform, Region, Site +from tenancy.models import Tenant, TenantGroup from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction +from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -124,6 +125,92 @@ class TopologyMapFilter(django_filters.FilterSet): fields = ['name', 'slug'] +class ConfigContextFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + region_id = django_filters.ModelMultipleChoiceFilter( + name='regions', + queryset=Region.objects.all(), + label='Region', + ) + region = django_filters.ModelMultipleChoiceFilter( + name='regions__slug', + queryset=Region.objects.all(), + to_field_name='slug', + label='Region (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='sites', + queryset=Site.objects.all(), + label='Site', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='sites__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='roles', + queryset=DeviceRole.objects.all(), + label='Role', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='roles__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + platform_id = django_filters.ModelMultipleChoiceFilter( + name='platforms', + queryset=Platform.objects.all(), + label='Platform', + ) + platform = django_filters.ModelMultipleChoiceFilter( + name='platforms__slug', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', + ) + tenant_group_id = django_filters.ModelMultipleChoiceFilter( + name='tenant_groups', + queryset=TenantGroup.objects.all(), + label='Tenant group', + ) + tenant_group = django_filters.ModelMultipleChoiceFilter( + name='tenant_groups__slug', + queryset=TenantGroup.objects.all(), + to_field_name='slug', + label='Tenant group (slug)', + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + name='tenants', + queryset=Tenant.objects.all(), + label='Tenant', + ) + tenant = django_filters.ModelMultipleChoiceFilter( + name='tenants__slug', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) + + class Meta: + model = ConfigContext + fields = ['name', 'is_active'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(data__icontains=value) + ) + + class ObjectChangeFilter(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 0b8d27233..7dfceb390 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from taggit.models import Tag -from dcim.models import Region -from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField +from dcim.models import DeviceRole, Platform, Region, Site +from tenancy.models import Tenant, TenantGroup +from utilities.forms import ( + add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, + JSONField, SlugField, +) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, OBJECTCHANGE_ACTION_CHOICES, @@ -223,6 +227,37 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): ] +class ConfigContextFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug' + ) + role = FilterChoiceField( + queryset=DeviceRole.objects.all(), + to_field_name='slug' + ) + platform = FilterChoiceField( + queryset=Platform.objects.all(), + to_field_name='slug' + ) + tenant_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug' + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.all(), + to_field_name='slug' + ) + + # # Image attachments # diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index dd73bfe3e..22bf26cce 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -72,15 +72,10 @@ class ConfigContextTable(BaseTable): is_active = BooleanColumn( verbose_name='Active' ) - actions = tables.TemplateColumn( - template_code=CONFIGCONTEXT_ACTIONS, - attrs={'td': {'class': 'text-right'}}, - verbose_name='' - ) class Meta(BaseTable.Meta): model = ConfigContext - fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') + fields = ('pk', 'name', 'weight', 'is_active', 'description') class ObjectChangeTable(BaseTable): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 7c0ab67d3..90d0d698d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,7 +14,7 @@ from taggit.models import Tag from utilities.forms import ConfirmationForm from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters -from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm +from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .reports import get_report, get_reports from .tables import ConfigContextTable, ObjectChangeTable, TagTable @@ -56,6 +56,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConfigContextListView(ObjectListView): queryset = ConfigContext.objects.all() + filter = filters.ConfigContextFilter + filter_form = ConfigContextFilterForm table = ConfigContextTable template_name = 'extras/configcontext_list.html' diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index c87ff9039..c987daf33 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -140,6 +140,20 @@ {% endif %} + + Tenant Groups + + {% if configcontext.tenant_groups.all %} +
    + {% for tenant_group in configcontext.tenant_groups.all %} +
  • {{ tenant_group }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} + + Tenants diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html index 98913d987..c35ba76ff 100644 --- a/netbox/templates/extras/configcontext_list.html +++ b/netbox/templates/extras/configcontext_list.html @@ -9,8 +9,11 @@

{% block title %}Config Contexts{% endblock %}

-
+
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
+
+ {% include 'inc/search_panel.html' %} +
{% endblock %} From 9914576eaa06eaca56de9e052400c64db33d7115 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 15:46:18 -0400 Subject: [PATCH 08/11] Fixes #2344: AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site --- netbox/dcim/forms.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 521c3c858..4e201639c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1795,7 +1795,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): # Compile VLAN choices vlan_choices = [] - # Add global VLANs + # Add non-grouped global VLANs global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) vlan_choices.append(( 'Global', [(vlan.pk, vlan) for vlan in global_vlans]) @@ -1808,16 +1808,15 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) ) - parent = self.instance.parent - if parent is not None: + site = getattr(self.instance.parent, 'site', None) + if site is not None: - # Add site VLANs - if parent.site: - site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans])) + # Add non-grouped site VLANs + site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans) + vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=parent.site): + for group in VLANGroup.objects.filter(site=site): site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) vlan_choices.append(( '{} / {}'.format(group.site.name, group.name), From 17714b0c12d52de0a70403f9afba2cd2e52c6063 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 16:34:17 -0400 Subject: [PATCH 09/11] Fixes #2342: IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM --- netbox/virtualization/models.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3d8a51fff..119c9ee4f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -260,6 +260,22 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): def get_absolute_url(self): return reverse('virtualization:virtualmachine', args=[self.pk]) + def clean(self): + + # Validate primary IP addresses + interfaces = self.interfaces.all() + for field in ['primary_ip4', 'primary_ip6']: + ip = getattr(self, field) + if ip is not None: + if ip.interface in interfaces: + pass + elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces: + pass + else: + raise ValidationError({ + field: "The specified IP address ({}) is not assigned to this VM.".format(ip), + }) + def to_csv(self): return ( self.name, From f43d861b505c555a8dde7708cf19d4e50fed7053 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 16:36:23 -0400 Subject: [PATCH 10/11] Release v2.4.3 --- 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 360890656..8d2a5feeb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.3-dev' +VERSION = '2.4.3' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 9d9318f38a16ff89abae576a35fa59030a47684e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Aug 2018 16:37:58 -0400 Subject: [PATCH 11/11] Corrected typo --- netbox/templates/dcim/device.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index c84bdf9a3..09812e105 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -631,7 +631,7 @@ {% endif %} {% if power_outlets or device.device_type.is_pdu %} {% if perms.dcim.delete_poweroutlet %} - + {% csrf_token %} {% endif %}