Просмотр исходного кода

Merge pull request #2346 from digitalocean/develop

Release v2.4.3
Jeremy Stretch 7 лет назад
Родитель
Сommit
f224ad2959

+ 2 - 2
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:

+ 7 - 8
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),

+ 4 - 1
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
 
 
 #

+ 89 - 2
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',

+ 37 - 2
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
 #

+ 1 - 6
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):

+ 3 - 1
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'
 

+ 6 - 4
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():

+ 6 - 2
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

+ 1 - 1
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__)))
 

+ 6 - 6
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 %}
-                    <form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}">
+                    <form method="post">
                     {% csrf_token %}
                 {% endif %}
                 <div class="panel panel-default">
@@ -606,12 +606,12 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
-                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
                             </button>
                         {% endif %}
                         {% if cs_ports and perms.dcim.delete_consoleserverport %}
-                            <button type="submit" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                         {% endif %}
@@ -631,7 +631,7 @@
             {% endif %}
             {% if power_outlets or device.device_type.is_pdu %}
                 {% if perms.dcim.delete_poweroutlet %}
-                    <form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}">
+                    <form method="post">
                     {% csrf_token %}
                 {% endif %}
                 <div class="panel panel-default">
@@ -664,12 +664,12 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
-                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
                             </button>
                         {% endif %}
                         {% if power_outlets and perms.dcim.delete_poweroutlet %}
-                            <button type="submit" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                         {% endif %}

+ 14 - 0
netbox/templates/extras/configcontext.html

@@ -140,6 +140,20 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <td>Tenant Groups</td>
+                        <td>
+                            {% if configcontext.tenant_groups.all %}
+                                <ul>
+                                    {% for tenant_group in configcontext.tenant_groups.all %}
+                                        <li><a href="{{ tenant_group.get_absolute_url }}">{{ tenant_group }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                         <td>Tenants</td>
                         <td>

+ 4 - 1
netbox/templates/extras/configcontext_list.html

@@ -9,8 +9,11 @@
     </div>
     <h1>{% block title %}Config Contexts{% endblock %}</h1>
     <div class="row">
-        <div class="col-md-12">
+        <div class="col-md-9">
             {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
         </div>
+        <div class="col-md-3">
+            {% include 'inc/search_panel.html' %}
+        </div>
     </div>
 {% endblock %}

+ 3 - 1
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")
 

+ 16 - 0
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,