Status | ++ {{ circuit.get_status_display }} + | +||||
Provider |
diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html
index 63d38ef52..8503e68f6 100644
--- a/netbox/templates/circuits/circuit_edit.html
+++ b/netbox/templates/circuits/circuit_edit.html
@@ -8,6 +8,7 @@
{% render_field form.provider %}
{% render_field form.cid %}
{% render_field form.type %}
+ {% render_field form.status %}
{% render_field form.install_date %}
diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html
index de9922313..f05552f7d 100644
--- a/netbox/templates/circuits/circuit_list.html
+++ b/netbox/templates/circuits/circuit_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html
index af48ecd0c..2b9469042 100644
--- a/netbox/templates/circuits/circuittype_list.html
+++ b/netbox/templates/circuits/circuittype_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/dcim/bulk_rename.html b/netbox/templates/dcim/bulk_rename.html
new file mode 100644
index 000000000..6abcaf305
--- /dev/null
+++ b/netbox/templates/dcim/bulk_rename.html
@@ -0,0 +1,55 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block content %}
+ {% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}+
+
+{% endblock %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 349a3c96d..e2253d4f4 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -98,6 +98,46 @@
+
+
+
+
+ |
Device | +Position | +Master | +Priority | +
---|---|---|---|
+ {{ vc_member }} + | +{{ vc_member.vc_position }} | +{% if device.virtual_chassis.master == vc_member %}{% endif %} | +{{ vc_member.vc_priority|default:"" }} | +
+ {% endif %} + | Name | +Installed Device | ++ | |
---|---|---|---|---|
— No device bays defined — | +
No device bays defined | -
+ {% endif %} + | Name | +LAG | +Description | +MTU | +MAC Address | +Connection | ++ | |
---|---|---|---|---|---|---|---|---|
— No interfaces defined — | +
- {% endif %} - | Name | -LAG | -Description | -MTU | -MAC Address | -Connection | -- | |
---|---|---|---|---|---|---|---|---|
No interfaces defined | -
+ {% endif %} + | Name | +Connection | ++ | |
---|---|---|---|---|
— No console server ports defined — | +
- {% endif %} - | Name | -Connection | -- | |
---|---|---|---|---|
No console server ports defined | -
+ {% endif %} + | Name | +Connection | ++ | |
---|---|---|---|---|
— No power outlets defined — | +
- {% endif %} - | Name | -Connection | -- | |
---|---|---|---|---|
No power outlets defined | -
Units | +Tenant | Description | |||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
{{ resv.unit_list }} | ++ {% if resv.tenant %} + {{ resv.tenant }} + {% else %} + None + {% endif %} + |
{{ resv.description }} {{ resv.user }} · {{ resv.created }} diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 5304538c1..38a821750 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -45,9 +45,10 @@ {% endblock %} {% block javascript %} - + {% include 'dcim/inc/filter_rack_group.html' %} + {% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index eb00800ec..d5734ee2b 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
@@ -22,34 +21,6 @@
{% endblock %}
{% block javascript %}
-
+ {% include 'dcim/inc/filter_rack_group.html' %}
{% endblock %}
diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html
index 51989db0f..c16b1605f 100644
--- a/netbox/templates/dcim/rackgroup_list.html
+++ b/netbox/templates/dcim/rackgroup_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
{% endblock %}
diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html
index 8e6d28d49..d65904595 100644
--- a/netbox/templates/ipam/prefix_list.html
+++ b/netbox/templates/ipam/prefix_list.html
@@ -1,7 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
-{% load form_helpers %}
{% block content %}
diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html
index d6b9f1c5a..0f6d39c15 100644
--- a/netbox/templates/dcim/region_list.html
+++ b/netbox/templates/dcim/region_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index efc98c3d0..b14c2019d 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
+{% load tz %}
{% load helpers %}
{% block content %}
@@ -57,6 +58,12 @@
Site
diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html
index a1c13075a..582f93996 100644
--- a/netbox/templates/dcim/site_edit.html
+++ b/netbox/templates/dcim/site_edit.html
@@ -7,9 +7,11 @@
{% render_field form.name %}
{% render_field form.slug %}
+ {% render_field form.status %}
{% render_field form.region %}
{% render_field form.facility %}
{% render_field form.asn %}
+ {% render_field form.time_zone %}
diff --git a/netbox/templates/dcim/virtualchassis_add_member.html b/netbox/templates/dcim/virtualchassis_add_member.html
new file mode 100644
index 000000000..cef1a2a2e
--- /dev/null
+++ b/netbox/templates/dcim/virtualchassis_add_member.html
@@ -0,0 +1,35 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html
new file mode 100644
index 000000000..097cb487f
--- /dev/null
+++ b/netbox/templates/dcim/virtualchassis_edit.html
@@ -0,0 +1,103 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html
new file mode 100644
index 000000000..e8d4f3366
--- /dev/null
+++ b/netbox/templates/dcim/virtualchassis_list.html
@@ -0,0 +1,14 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+
{% endblock %}
diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html
index 9e378de54..5f8fdeb88 100644
--- a/netbox/templates/ipam/ipaddress_list.html
+++ b/netbox/templates/ipam/ipaddress_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
{% block title %}Virtual Chassis{% endblock %}+
+
+{% endblock %}
diff --git a/netbox/templates/dcim/virtualchassis_remove_member.html b/netbox/templates/dcim/virtualchassis_remove_member.html
new file mode 100644
index 000000000..0da7c1d1b
--- /dev/null
+++ b/netbox/templates/dcim/virtualchassis_remove_member.html
@@ -0,0 +1,8 @@
+{% extends 'utilities/confirmation_form.html' %}
+{% load form_helpers %}
+
+{% block title %}Remove Virtual Chassis Member?{% endblock %}
+
+{% block message %}
+
+ {% include 'utilities/obj_table.html' %}
+
+
+ {% include 'inc/search_panel.html' %}
+
+Are you sure you want to remove {{ device }} from virtual chassis {{ device.virtual_chassis }}? +{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 1857afcc2..a85647993 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -104,7 +104,7 @@ -
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index 855fc3a98..1509f35cb 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -144,7 +144,7 @@
{% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %}
- {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+ {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index 5c168e247..11c5fc405 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -136,7 +136,7 @@
{% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %}
- {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
+ {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html
index 40a21fc25..67356b3cb 100644
--- a/netbox/templates/ipam/rir_list.html
+++ b/netbox/templates/ipam/rir_list.html
@@ -1,7 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load humanize %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html
index bc493e15b..cd6fcd7aa 100644
--- a/netbox/templates/ipam/role_list.html
+++ b/netbox/templates/ipam/role_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html
index 29fc6a79d..24e12595b 100644
--- a/netbox/templates/ipam/vlan_list.html
+++ b/netbox/templates/ipam/vlan_list.html
@@ -1,7 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
-{% load form_helpers %}
{% block content %}
diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html
index 6eb63afdc..9333f95c7 100644
--- a/netbox/templates/ipam/vlangroup_list.html
+++ b/netbox/templates/ipam/vlangroup_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html
index 479947554..23bd16495 100644
--- a/netbox/templates/ipam/vrf_list.html
+++ b/netbox/templates/ipam/vrf_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
-{% load helpers %}
-{% load form_helpers %}
+{% load buttons %}
{% block content %}
diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html
index 4e2aa9cb9..6dd92cd89 100644
--- a/netbox/templates/secrets/secret_list.html
+++ b/netbox/templates/secrets/secret_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html
index c76c8f748..e968630f6 100644
--- a/netbox/templates/secrets/secretrole_list.html
+++ b/netbox/templates/secrets/secretrole_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index c19195246..d5eb7df98 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -100,6 +100,10 @@
+ {{ stats.rack_count }}Racks
+
{{ stats.rackreservation_count }}+Rack reservations +{{ stats.device_count }}Devices diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index c2181f1b8..e6fd61c37 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -1,6 +1,5 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html
index 26bbb86bd..a62594994 100644
--- a/netbox/templates/tenancy/tenantgroup_list.html
+++ b/netbox/templates/tenancy/tenantgroup_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html
index a5d042f65..d724c2c43 100644
--- a/netbox/templates/virtualization/clustergroup_list.html
+++ b/netbox/templates/virtualization/clustergroup_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html
index b05ae9afe..37f8cc31b 100644
--- a/netbox/templates/virtualization/clustertype_list.html
+++ b/netbox/templates/virtualization/clustertype_list.html
@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
-{% load helpers %}
{% block content %}
-
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 7f9e948e7..944792705 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -235,42 +235,39 @@
- {% if perms.dcim.change_interface and interfaces|length > 1 %}
-
- {% endif %}
- {% if perms.dcim.add_interface and interfaces|length > 10 %}
-
- Add interfaces
-
- {% endif %}
@@ -18,6 +16,15 @@
{% render_field form.cluster %}
+
+
Management
+
+ {% render_field form.status %}
+ {% render_field form.platform %}
+ {% render_field form.primary_ip4 %}
+ {% render_field form.primary_ip6 %}
+
+ Resources
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index a52ac2c60..454e41c52 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -35,7 +35,7 @@ class TenantSerializer(CustomFieldModelSerializer):
class Meta:
model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
class NestedTenantSerializer(serializers.ModelSerializer):
@@ -50,4 +50,4 @@ class WritableTenantSerializer(CustomFieldModelSerializer):
class Meta:
model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index c1f7d990d..26f9bc71e 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -1,11 +1,9 @@
from __future__ import unicode_literals
-from rest_framework.viewsets import ModelViewSet
-
from extras.api.views import CustomFieldModelViewSet
from tenancy import filters
from tenancy.models import Tenant, TenantGroup
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
from . import serializers
@@ -31,7 +29,7 @@ class TenantGroupViewSet(ModelViewSet):
# Tenants
#
-class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related('group')
serializer_class = serializers.TenantSerializer
write_serializer_class = serializers.WritableTenantSerializer
diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py
index 1ac05ea89..f1238eddb 100644
--- a/netbox/tenancy/tests/test_api.py
+++ b/netbox/tenancy/tests/test_api.py
@@ -44,7 +44,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('tenancy-api:tenantgroup-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(TenantGroup.objects.count(), 4)
@@ -52,6 +52,32 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(tenantgroup4.name, data['name'])
self.assertEqual(tenantgroup4.slug, data['slug'])
+ def test_create_tenantgroup_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Tenant Group 4',
+ 'slug': 'test-tenant-group-4',
+ },
+ {
+ 'name': 'Test Tenant Group 5',
+ 'slug': 'test-tenant-group-5',
+ },
+ {
+ 'name': 'Test Tenant Group 6',
+ 'slug': 'test-tenant-group-6',
+ },
+ ]
+
+ url = reverse('tenancy-api:tenantgroup-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(TenantGroup.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_tenantgroup(self):
data = {
@@ -60,7 +86,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(TenantGroup.objects.count(), 3)
@@ -114,7 +140,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
}
url = reverse('tenancy-api:tenant-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tenant.objects.count(), 4)
@@ -123,6 +149,32 @@ class TenantTest(HttpStatusMixin, APITestCase):
self.assertEqual(tenant4.slug, data['slug'])
self.assertEqual(tenant4.group_id, data['group'])
+ def test_create_tenant_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Tenant 4',
+ 'slug': 'test-tenant-4',
+ },
+ {
+ 'name': 'Test Tenant 5',
+ 'slug': 'test-tenant-5',
+ },
+ {
+ 'name': 'Test Tenant 6',
+ 'slug': 'test-tenant-6',
+ },
+ ]
+
+ url = reverse('tenancy-api:tenant-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Tenant.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_tenant(self):
data = {
@@ -132,7 +184,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
}
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tenant.objects.count(), 3)
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 33df6a5ca..99c4acc8a 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -7,7 +7,7 @@ from django.urls import reverse
from django.views.generic import View
from circuits.models import Circuit
-from dcim.models import Site, Rack, Device
+from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import IPAddress, Prefix, VLAN, VRF
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -75,6 +75,7 @@ class TenantView(View):
stats = {
'site_count': Site.objects.filter(tenant=tenant).count(),
'rack_count': Rack.objects.filter(tenant=tenant).count(),
+ 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
'device_count': Device.objects.filter(tenant=tenant).count(),
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 91e0fb8af..5c78dacc4 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -1,15 +1,18 @@
from __future__ import unicode_literals
from collections import OrderedDict
+import pytz
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
+from django.db.models import ManyToManyField
from django.http import Http404
+from rest_framework import mixins
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
-from rest_framework.viewsets import ViewSet
+from rest_framework.viewsets import GenericViewSet, ViewSet
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@@ -49,6 +52,11 @@ class ValidatedModelSerializer(ModelSerializer):
# Run clean() on an instance of the model
if self.instance is None:
+ model = self.Meta.model
+ # Ignore ManyToManyFields for new instances (a PK is needed for validation)
+ for field in model._meta.get_fields():
+ if isinstance(field, ManyToManyField) and field.name in attrs:
+ attrs.pop(field.name)
instance = self.Meta.model(**attrs)
else:
instance = self.instance
@@ -96,10 +104,51 @@ class ContentTypeFieldSerializer(Field):
raise ValidationError("Invalid content type")
+class TimeZoneField(Field):
+ """
+ Represent a pytz time zone.
+ """
+
+ def to_representation(self, obj):
+ return obj.zone if obj else None
+
+ def to_internal_value(self, data):
+ if not data:
+ return ""
+ try:
+ return pytz.timezone(str(data))
+ except pytz.exceptions.UnknownTimeZoneError:
+ raise ValidationError('Invalid time zone "{}"'.format(data))
+
+
#
-# Views
+# Viewsets
#
+class ModelViewSet(mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.DestroyModelMixin,
+ mixins.ListModelMixin,
+ GenericViewSet):
+ """
+ Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
+ 1. Use an alternate serializer (if provided) for write operations
+ 2. Accept either a single object or a list of objects to create
+ """
+ def get_serializer_class(self):
+ # Check for a different serializer to use for write operations
+ if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
+ return self.write_serializer_class
+ return self.serializer_class
+
+ def get_serializer(self, *args, **kwargs):
+ # If a list of objects has been provided, initialize the serializer with many=True
+ if isinstance(kwargs.get('data', {}), list):
+ kwargs['many'] = True
+ return super(ModelViewSet, self).get_serializer(*args, **kwargs)
+
+
class FieldChoicesViewSet(ViewSet):
"""
Expose the built-in numeric values which represent static choices for a model's field.
@@ -135,25 +184,9 @@ class FieldChoicesViewSet(ViewSet):
return Response(self._fields)
def retrieve(self, request, pk):
-
if pk not in self._fields:
raise Http404
-
return Response(self._fields[pk])
def get_view_name(self):
return "Field Choices"
-
-
-#
-# Mixins
-#
-
-class WritableSerializerMixin(object):
- """
- Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
- """
- def get_serializer_class(self):
- if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
- return self.write_serializer_class
- return self.serializer_class
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py
new file mode 100644
index 000000000..1cb3999ef
--- /dev/null
+++ b/netbox/utilities/constants.py
@@ -0,0 +1,7 @@
+from utilities.forms import ChainedModelMultipleChoiceField
+
+
+# Fields which are used on ManyToMany relationships
+M2M_FIELD_TYPES = [
+ ChainedModelMultipleChoiceField,
+]
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index a20825d13..a2bfef001 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -119,7 +119,7 @@ class ColorSelect(forms.Select):
"""
Extends the built-in Select widget to colorize each
\ No newline at end of file
diff --git a/netbox/utilities/templates/selectwithdisabled_option.html b/netbox/utilities/templates/widgets/selectwithdisabled_option.html
similarity index 100%
rename from netbox/utilities/templates/selectwithdisabled_option.html
rename to netbox/utilities/templates/widgets/selectwithdisabled_option.html
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 4ed1aeced..7d79a5f2a 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -1,5 +1,8 @@
from __future__ import unicode_literals
+import datetime
+import pytz
+
from django import template
from django.utils.safestring import mark_safe
from markdown import markdown
@@ -117,6 +120,14 @@ def example_choices(field, arg=3):
return ', '.join(examples) or 'None'
+@register.filter()
+def tzoffset(value):
+ """
+ Returns the hour offset of a given time zone using the current time.
+ """
+ return datetime.datetime.now(value).strftime('%z')
+
+
#
# Tags
#
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 73f4ee132..b2a8b007c 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -9,9 +9,9 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ProtectedError
-from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
+from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.shortcuts import get_object_or_404, redirect, render
-from django.template import TemplateSyntaxError
+from django.template.exceptions import TemplateSyntaxError
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url
@@ -23,6 +23,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAct
from extras.webhooks import bulk_operation_signal
from utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField
+from .constants import M2M_FIELD_TYPES
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .paginator import EnhancedPaginator
@@ -35,6 +36,7 @@ class CustomFieldQueryset:
def __init__(self, queryset, custom_fields):
self.queryset = queryset
+ self.model = queryset.model
self.custom_fields = custom_fields
def __iter__(self):
@@ -506,45 +508,65 @@ class BulkEditView(View):
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST:
- form = self.form(self.cls, request.POST)
+ form = self.form(self.cls, parent_obj, request.POST)
if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
-
- # Update standard fields. If a field is listed in _nullify, delete its value.
nullified_fields = request.POST.getlist('_nullify')
- fields_to_update = {}
- for field in standard_fields:
- if field in form.nullable_fields and field in nullified_fields:
- if isinstance(form.fields[field], CharField):
- fields_to_update[field] = ''
- else:
- fields_to_update[field] = None
- elif form.cleaned_data[field] not in (None, ''):
- fields_to_update[field] = form.cleaned_data[field]
- updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
- # Update custom fields for objects
- if custom_fields:
- objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
- if objs_updated and not updated_count:
- updated_count = objs_updated
+ try:
- if updated_count:
- msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
- messages.success(self.request, msg)
- UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
+ with transaction.atomic():
- # send the bulk operations signal for webhooks
- instances = self.cls.objects.filter(pk__in=pk_list)
- bulk_operation_signal.send(sender=self.cls, instances=instances, event="updated")
- return redirect(return_url)
+ updated_count = 0
+ for obj in self.cls.objects.filter(pk__in=pk_list):
+
+ # Update standard fields. If a field is listed in _nullify, delete its value.
+ for name in standard_fields:
+ if name in form.nullable_fields and name in nullified_fields:
+ setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
+ elif form.cleaned_data[name] not in (None, ''):
+ setattr(obj, name, form.cleaned_data[name])
+ obj.full_clean()
+ obj.save()
+
+ # Update custom fields
+ obj_type = ContentType.objects.get_for_model(self.cls)
+ for name in custom_fields:
+ field = form.fields[name].model
+ if name in form.nullable_fields and name in nullified_fields:
+ CustomFieldValue.objects.filter(
+ field=field, obj_type=obj_type, obj_id=obj.pk
+ ).delete()
+ elif form.cleaned_data[name] not in [None, '']:
+ try:
+ cfv = CustomFieldValue.objects.get(
+ field=field, obj_type=obj_type, obj_id=obj.pk
+ )
+ except CustomFieldValue.DoesNotExist:
+ cfv = CustomFieldValue(
+ field=field, obj_type=obj_type, obj_id=obj.pk
+ )
+ cfv.value = form.cleaned_data[name]
+ cfv.save()
+
+ updated_count += 1
+
+ if updated_count:
+ msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
+ messages.success(self.request, msg)
+ UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
+
+ return redirect(return_url)
+
+ except ValidationError as e:
+ messages.error(self.request, "{} failed validation: {}".format(obj, e))
else:
initial_data = request.POST.copy()
initial_data['pk'] = pk_list
- form = self.form(self.cls, initial=initial_data)
+ form = self.form(self.cls, parent_obj, initial=initial_data)
# Retrieve objects being edited
queryset = self.queryset or self.cls.objects.all()
@@ -560,53 +582,6 @@ class BulkEditView(View):
'return_url': return_url,
})
- def update_custom_fields(self, pk_list, form, fields, nullified_fields):
- obj_type = ContentType.objects.get_for_model(self.cls)
- objs_updated = False
-
- for name in fields:
-
- field = form.fields[name].model
-
- # Setting the field to null
- if name in form.nullable_fields and name in nullified_fields:
-
- # Delete all CustomFieldValues for instances of this field belonging to the selected objects.
- CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
- objs_updated = True
-
- # Updating the value of the field
- elif form.cleaned_data[name] not in [None, '']:
-
- # Check for zero value (bulk editing)
- if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
- serialized_value = field.serialize_value(None)
- else:
- serialized_value = field.serialize_value(form.cleaned_data[name])
-
- # Gather any pre-existing CustomFieldValues for the objects being edited.
- existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
-
- # Determine which objects have an existing CFV to update and which need a new CFV created.
- update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
- create_list = list(set(pk_list) - set(update_list))
-
- # Creating/updating CFVs
- if serialized_value:
- existing_cfvs.update(serialized_value=serialized_value)
- CustomFieldValue.objects.bulk_create([
- CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
- for pk in create_list
- ])
-
- # Deleting CFVs
- else:
- existing_cfvs.delete()
-
- objs_updated = True
-
- return len(pk_list) if objs_updated else 0
-
class BulkDeleteView(View):
"""
@@ -769,6 +744,25 @@ class ComponentCreateView(View):
if not form.errors:
self.model.objects.bulk_create(new_components)
+ # ManyToMany relations are bulk created via the through model
+ m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
+ if m2m_fields:
+ for field in m2m_fields:
+ field_links = []
+ for new_component in new_components:
+ for related_obj in component_form.cleaned_data[field]:
+ # The through model columns are the id's of our M2M relation objects
+ through_kwargs = {}
+ new_component_column = new_component.__class__.__name__ + '_id'
+ related_obj_column = related_obj.__class__.__name__ + '_id'
+ through_kwargs.update({
+ new_component_column.lower(): new_component.id,
+ related_obj_column.lower(): related_obj.id
+ })
+ field_link = getattr(self.model, field).through(**through_kwargs)
+ field_links.append(field_link)
+ getattr(self.model, field).through.objects.bulk_create(field_links)
+
# send the bulk operations signal for webhooks
bulk_operation_signal.send(sender=self.model, instances=new_components, event="created")
@@ -788,20 +782,6 @@ class ComponentCreateView(View):
})
-class ComponentEditView(ObjectEditView):
- parent_field = None
-
- def get_return_url(self, request, obj):
- return getattr(obj, self.parent_field).get_absolute_url()
-
-
-class ComponentDeleteView(ObjectDeleteView):
- parent_field = None
-
- def get_return_url(self, request, obj):
- return getattr(obj, self.parent_field).get_absolute_url()
-
-
class BulkComponentCreateView(View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 078df19b6..7e2ec1690 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -9,7 +9,7 @@ from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
-from virtualization.constants import STATUS_CHOICES
+from virtualization.constants import VM_STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -62,7 +62,7 @@ class ClusterSerializer(CustomFieldModelSerializer):
class Meta:
model = Cluster
- fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
class NestedClusterSerializer(serializers.ModelSerializer):
@@ -77,7 +77,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
class Meta:
model = Cluster
- fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
#
@@ -94,7 +94,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
class VirtualMachineSerializer(CustomFieldModelSerializer):
- status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
+ status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
cluster = NestedClusterSerializer()
role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer()
@@ -107,7 +107,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
- 'vcpus', 'memory', 'disk', 'comments', 'custom_fields',
+ 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
]
@@ -125,7 +125,7 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
- 'memory', 'disk', 'comments', 'custom_fields',
+ 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
]
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 2b7ce4b60..149bb3145 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,10 +1,8 @@
from __future__ import unicode_literals
-from rest_framework.viewsets import ModelViewSet
-
from dcim.models import Interface
from extras.api.views import CustomFieldModelViewSet
-from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
+from utilities.api import FieldChoicesViewSet, ModelViewSet
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from . import serializers
@@ -27,14 +25,16 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.all()
serializer_class = serializers.ClusterTypeSerializer
+ filter_class = filters.ClusterTypeFilter
class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.all()
serializer_class = serializers.ClusterGroupSerializer
+ filter_class = filters.ClusterGroupFilter
-class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.select_related('type', 'group')
serializer_class = serializers.ClusterSerializer
write_serializer_class = serializers.WritableClusterSerializer
@@ -45,14 +45,14 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all()
serializer_class = serializers.VirtualMachineSerializer
write_serializer_class = serializers.WritableVirtualMachineSerializer
filter_class = filters.VirtualMachineFilter
-class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
+class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
serializer_class = serializers.InterfaceSerializer
write_serializer_class = serializers.WritableInterfaceSerializer
diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py
index 6324fb785..307921e0e 100644
--- a/netbox/virtualization/constants.py
+++ b/netbox/virtualization/constants.py
@@ -1,12 +1,12 @@
from __future__ import unicode_literals
-from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED
+from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED
# VirtualMachine statuses (replicated from Device statuses)
-STATUS_CHOICES = [
- [STATUS_ACTIVE, 'Active'],
- [STATUS_OFFLINE, 'Offline'],
- [STATUS_STAGED, 'Staged'],
+VM_STATUS_CHOICES = [
+ [DEVICE_STATUS_ACTIVE, 'Active'],
+ [DEVICE_STATUS_OFFLINE, 'Offline'],
+ [DEVICE_STATUS_STAGED, 'Staged'],
]
# Bootstrap CSS classes for VirtualMachine statuses
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index bd3e19400..53c3f18d9 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -9,10 +9,24 @@ from dcim.models import DeviceRole, Interface, Platform, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
-from .constants import STATUS_CHOICES
+from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+class ClusterTypeFilter(django_filters.FilterSet):
+
+ class Meta:
+ model = ClusterType
+ fields = ['name', 'slug']
+
+
+class ClusterGroupFilter(django_filters.FilterSet):
+
+ class Meta:
+ model = ClusterGroup
+ fields = ['name', 'slug']
+
+
class ClusterFilter(CustomFieldFilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
@@ -70,7 +84,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
label='Search',
)
status = django_filters.MultipleChoiceFilter(
- choices=STATUS_CHOICES,
+ choices=VM_STATUS_CHOICES,
null_value=None
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 34e3fd5cc..06b992203 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -16,7 +17,7 @@ from utilities.forms import (
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
)
-from .constants import STATUS_CHOICES
+from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
VIFACE_FF_CHOICES = (
@@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = VirtualMachine
fields = [
- 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
- 'comments',
+ 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+ 'vcpus', 'memory', 'disk', 'comments',
]
def __init__(self, *args, **kwargs):
@@ -261,10 +262,45 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
super(VirtualMachineForm, self).__init__(*args, **kwargs)
+ if self.instance.pk:
+
+ # Compile list of choices for primary IPv4 and IPv6 addresses
+ for family in [4, 6]:
+ ip_choices = [(None, '---------')]
+ # Collect interface IPs
+ interface_ips = IPAddress.objects.select_related('interface').filter(
+ family=family, interface__virtual_machine=self.instance
+ )
+ if interface_ips:
+ ip_choices.append(
+ ('Interface IPs', [
+ (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
+ ])
+ )
+ # Collect NAT IPs
+ nat_ips = IPAddress.objects.select_related('nat_inside').filter(
+ family=family, nat_inside__interface__virtual_machine=self.instance
+ )
+ if nat_ips:
+ ip_choices.append(
+ ('NAT IPs', [
+ (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
+ ])
+ )
+ self.fields['primary_ip{}'.format(family)].choices = ip_choices
+
+ else:
+
+ # An object that doesn't exist yet can't have any IPs assigned to it
+ self.fields['primary_ip4'].choices = []
+ self.fields['primary_ip4'].widget.attrs['readonly'] = True
+ self.fields['primary_ip6'].choices = []
+ self.fields['primary_ip6'].widget.attrs['readonly'] = True
+
class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField(
- choices=STATUS_CHOICES,
+ choices=VM_STATUS_CHOICES,
required=False,
help_text='Operational status of device'
)
@@ -311,7 +347,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
- status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
+ status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='')
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
@@ -329,7 +365,7 @@ def vm_status_choices():
status_counts = {}
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
- return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
+ return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -406,7 +442,6 @@ class InterfaceCreateForm(ComponentForm):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
- virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
description = forms.CharField(max_length=100, required=False)
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index ab4335f61..868d43398 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -10,7 +10,7 @@ from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Device
from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel
-from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
+from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
#
@@ -198,8 +198,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
unique=True
)
status = models.PositiveSmallIntegerField(
- choices=STATUS_CHOICES,
- default=STATUS_ACTIVE,
+ choices=VM_STATUS_CHOICES,
+ default=DEVICE_STATUS_ACTIVE,
verbose_name='Status'
)
role = models.ForeignKey(
@@ -294,3 +294,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
return self.primary_ip4
else:
return None
+
+ def site(self):
+ # used when a child compent (eg Interface) needs to know its parent's site but
+ # the parent could be either a device or a virtual machine
+ return self.cluster.site
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index f83e0ea58..1f9e72ee5 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -44,7 +44,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:clustertype-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterType.objects.count(), 4)
@@ -52,6 +52,32 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
self.assertEqual(clustertype4.name, data['name'])
self.assertEqual(clustertype4.slug, data['slug'])
+ def test_create_clustertype_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Cluster Type 4',
+ 'slug': 'test-cluster-type-4',
+ },
+ {
+ 'name': 'Test Cluster Type 5',
+ 'slug': 'test-cluster-type-5',
+ },
+ {
+ 'name': 'Test Cluster Type 6',
+ 'slug': 'test-cluster-type-6',
+ },
+ ]
+
+ url = reverse('virtualization-api:clustertype-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(ClusterType.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_clustertype(self):
data = {
@@ -60,7 +86,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterType.objects.count(), 3)
@@ -111,7 +137,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:clustergroup-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterGroup.objects.count(), 4)
@@ -119,6 +145,32 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(clustergroup4.name, data['name'])
self.assertEqual(clustergroup4.slug, data['slug'])
+ def test_create_clustergroup_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Cluster Group 4',
+ 'slug': 'test-cluster-group-4',
+ },
+ {
+ 'name': 'Test Cluster Group 5',
+ 'slug': 'test-cluster-group-5',
+ },
+ {
+ 'name': 'Test Cluster Group 6',
+ 'slug': 'test-cluster-group-6',
+ },
+ ]
+
+ url = reverse('virtualization-api:clustergroup-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(ClusterGroup.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_clustergroup(self):
data = {
@@ -127,7 +179,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterGroup.objects.count(), 3)
@@ -182,7 +234,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:cluster-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cluster.objects.count(), 4)
@@ -191,6 +243,35 @@ class ClusterTest(HttpStatusMixin, APITestCase):
self.assertEqual(cluster4.type.pk, data['type'])
self.assertEqual(cluster4.group.pk, data['group'])
+ def test_create_cluster_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Cluster 4',
+ 'type': ClusterType.objects.first().pk,
+ 'group': ClusterGroup.objects.first().pk,
+ },
+ {
+ 'name': 'Test Cluster 5',
+ 'type': ClusterType.objects.first().pk,
+ 'group': ClusterGroup.objects.first().pk,
+ },
+ {
+ 'name': 'Test Cluster 6',
+ 'type': ClusterType.objects.first().pk,
+ 'group': ClusterGroup.objects.first().pk,
+ },
+ ]
+
+ url = reverse('virtualization-api:cluster-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(Cluster.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_cluster(self):
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
@@ -202,7 +283,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Cluster.objects.count(), 3)
@@ -230,11 +311,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
- cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
+ self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
- self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
- self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
- self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
+ self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
+ self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
+ self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
def test_get_virtualmachine(self):
@@ -254,11 +335,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
data = {
'name': 'Test Virtual Machine 4',
- 'cluster': Cluster.objects.first().pk,
+ 'cluster': self.cluster1.pk,
}
url = reverse('virtualization-api:virtualmachine-list')
- response = self.client.post(url, data, **self.header)
+ response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 4)
@@ -266,6 +347,32 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
self.assertEqual(virtualmachine4.name, data['name'])
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
+ def test_create_virtualmachine_bulk(self):
+
+ data = [
+ {
+ 'name': 'Test Virtual Machine 4',
+ 'cluster': self.cluster1.pk,
+ },
+ {
+ 'name': 'Test Virtual Machine 5',
+ 'cluster': self.cluster1.pk,
+ },
+ {
+ 'name': 'Test Virtual Machine 6',
+ 'cluster': self.cluster1.pk,
+ },
+ ]
+
+ url = reverse('virtualization-api:virtualmachine-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(VirtualMachine.objects.count(), 6)
+ self.assertEqual(response.data[0]['name'], data[0]['name'])
+ self.assertEqual(response.data[1]['name'], data[1]['name'])
+ self.assertEqual(response.data[2]['name'], data[2]['name'])
+
def test_update_virtualmachine(self):
cluster2 = Cluster.objects.create(
@@ -279,7 +386,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
}
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
- response = self.client.put(url, data, **self.header)
+ response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VirtualMachine.objects.count(), 3)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 119388dd9..82267fc00 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -11,8 +11,8 @@ from dcim.models import Device, Interface
from dcim.tables import DeviceTable
from ipam.models import Service
from utilities.views import (
- BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
- ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
+ ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -325,17 +325,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'virtualization/virtualmachine_component_add.html'
-class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
- parent_field = 'virtual_machine'
model_form = forms.InterfaceForm
-class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
- parent_field = 'virtual_machine'
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
diff --git a/old_requirements.txt b/old_requirements.txt
index 904545b4f..610ec6c44 100644
--- a/old_requirements.txt
+++ b/old_requirements.txt
@@ -1 +1,2 @@
+psycopg2
pycrypto
diff --git a/requirements.txt b/requirements.txt
index 303d2ad47..89c880815 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,19 +1,20 @@
Django>=1.11,<2.0
-django-cors-headers>=2.1
-django-debug-toolbar>=1.8
+django-cors-headers>=2.1.0
+django-debug-toolbar>=1.9.0
django-filter>=1.1.0
-django-mptt==0.8.7
+django-mptt>=0.9.0
django-rest-swagger>=2.1.0
-django-tables2>=1.10.0
-djangorestframework>=3.6.4
-graphviz>=0.6
-Markdown>=2.6.7
-natsort>=5.0.0
+django-tables2>=1.19.0
+django-timezone-field>=2.0
+djangorestframework>=3.7.7
+graphviz>=0.8.2
+Markdown>=2.6.11
+natsort>=5.2.0
ncclient==0.5.3
netaddr==0.7.18
-paramiko>=2.0.0
-Pillow>=4.0.0
-psycopg2>=2.7.3
+paramiko>=2.4.0
+Pillow>=5.0.0
+psycopg2-binary>=2.7.4
py-gfm>=0.1.3
-pycryptodome>=3.4.7
-xmltodict>=0.10.2
+pycryptodome>=3.4.11
+xmltodict>=0.11.0
|