diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bc9179673..0478932f7 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) @@ -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: 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), 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/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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c2b313b56..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.2' +VERSION = '2.4.3' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 62b54f361..09812e105 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 %} 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 %} 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") 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,