Compare commits

..

22 Commits

Author SHA1 Message Date
Jeremy Stretch
242cb7c7cb Merge pull request #2332 from digitalocean/develop
Release v2.4.2
2018-08-08 09:16:50 -04:00
Jeremy Stretch
edb49c7f0a Release v2.4.2 2018-08-08 09:12:10 -04:00
Jeremy Stretch
3e0a7e7f8a Added tip about exlcuding the changelog when exporting the database 2018-08-08 09:04:48 -04:00
Jeremy Stretch
cfab9a6a0a Fixes #2330: Incorrect tab link in VRF changelog view 2018-08-08 08:49:23 -04:00
Jeremy Stretch
91b5f6d799 Fixes #2323: DoesNotExist raised when deleting devices or virtual machines 2018-08-07 17:30:26 -04:00
Jeremy Stretch
d5488ca7da Fixes #2322: Webhooks firing on non-enabled event types 2018-08-07 15:41:31 -04:00
Jeremy Stretch
f9911bff0d Added a "view all" link to the changelog panel 2018-08-07 15:19:01 -04:00
Jeremy Stretch
d5239191fe Fixes #2320: TypeError when dispatching a webhook with a secret key configured 2018-08-07 14:19:46 -04:00
Jeremy Stretch
db7148350e Fixes #2321: Allow explicitly setting a null value on nullable ChoiceFields 2018-08-07 14:05:07 -04:00
Jeremy Stretch
c51c20a301 Fixes #2319: Extend ChoiceField to properly handle true/false choice keys 2018-08-07 13:48:29 -04:00
Jeremy Stretch
f4485dc72a Restore reports directory 2018-08-07 13:47:36 -04:00
Jeremy Stretch
f59682a7c9 Fixes #2318: ImportError when viewing a report 2018-08-07 12:10:14 -04:00
Jeremy Stretch
507a023f41 Post-release version bump 2018-08-07 09:26:17 -04:00
Jeremy Stretch
ea7386b04b Merge pull request #2316 from digitalocean/develop
Release v2.4.1
2018-08-07 09:25:10 -04:00
Jeremy Stretch
81479ac177 Release v2.4.1 2018-08-07 09:23:49 -04:00
Jeremy Stretch
c7acddbc5c Fixes #2312: Running a report yields a ValueError exception 2018-08-07 09:12:05 -04:00
Jeremy Stretch
1905516536 Fixes #2314: Serialized representation of object in change log does not incldue assigned tags 2018-08-07 08:52:57 -04:00
Jeremy Stretch
64f34d9cd7 Fixes #2311: Redirect to parent after editing interface from device/VM view 2018-08-07 08:46:41 -04:00
Jeremy Stretch
98bdb0cb3c Fixes #2310: False validation error on certain nested serializers 2018-08-06 17:40:45 -04:00
Jeremy Stretch
bba88b2be4 Fixes #2303: Always redirect to parent object when bulk editing/deleting components 2018-08-06 14:14:40 -04:00
Jeremy Stretch
12dfd4b6e0 Fixes #2308: Custom fields panel absent from object view in UI 2018-08-06 13:32:52 -04:00
Jeremy Stretch
209e721219 Post-release version bump 2018-08-06 12:45:46 -04:00
20 changed files with 133 additions and 56 deletions

View File

@@ -7,10 +7,18 @@ NetBox uses [PostgreSQL](https://www.postgresql.org/) for its database, so gener
## Export the Database ## Export the Database
Use the `pg_dump` utility to export the entire database to a file:
```no-highlight ```no-highlight
pg_dump netbox > netbox.sql pg_dump netbox > netbox.sql
``` ```
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight
pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql
```
## Load an Exported Database ## Load an Exported Database
!!! warning !!! warning

View File

@@ -123,7 +123,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
group = NestedRackGroupSerializer(required=False, allow_null=True) group = NestedRackGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@@ -223,7 +223,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False)
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False) subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
instance_count = serializers.IntegerField(source='instances.count', read_only=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@@ -362,7 +362,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
# #
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class DeviceIPAddressSerializer(serializers.ModelSerializer): class DeviceIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
@@ -371,7 +371,7 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency # Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
class NestedClusterSerializer(serializers.ModelSerializer): class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta: class Meta:
@@ -380,7 +380,7 @@ class NestedClusterSerializer(serializers.ModelSerializer):
# Cannot import NestedVirtualChassisSerializer due to circular dependency # Cannot import NestedVirtualChassisSerializer due to circular dependency
class DeviceVirtualChassisSerializer(serializers.ModelSerializer): class DeviceVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer() master = NestedDeviceSerializer()
@@ -396,7 +396,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack = NestedRackSerializer(required=False, allow_null=True) rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=RACK_FACE_CHOICES, required=False) face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True)
status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False) status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False)
primary_ip = DeviceIPAddressSerializer(read_only=True) primary_ip = DeviceIPAddressSerializer(read_only=True)
primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True)
@@ -576,7 +576,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
is_connected = serializers.SerializerMethodField(read_only=True) is_connected = serializers.SerializerMethodField(read_only=True)
interface_connection = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True)
circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField, JSONField from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist from django.db.models import Count, Q, ObjectDoesNotExist
@@ -1933,11 +1933,20 @@ class Interface(ComponentModel):
""" """
Include the connected Interface (if any). Include the connected Interface (if any).
""" """
# It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve
# the component parent will raise DoesNotExist. For more discussion, see
# https://github.com/digitalocean/netbox/issues/2323
try:
parent_obj = self.get_component_parent()
except ObjectDoesNotExist:
parent_obj = None
ObjectChange( ObjectChange(
user=user, user=user,
request_id=request_id, request_id=request_id,
changed_object=self, changed_object=self,
related_object=self.get_component_parent(), related_object=parent_obj,
action=action, action=action,
object_data=serialize_object(self, extra={ object_data=serialize_object(self, extra={
'connected_interface': self.connected_interface.pk if self.connection else None, 'connected_interface': self.connected_interface.pk if self.connection else None,

View File

@@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from dcim.constants import ( from dcim.constants import (
@@ -13,9 +14,10 @@ from dcim.models import (
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis, RackReservation, RackRole, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN from ipam.models import IPAddress, VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.testing import APITestCase from utilities.testing import APITestCase
from virtualization.models import Cluster, ClusterType
class RegionTest(APITestCase): class RegionTest(APITestCase):
@@ -1680,14 +1682,28 @@ class DeviceTest(APITestCase):
self.devicerole2 = DeviceRole.objects.create( self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00' name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
) )
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
self.device1 = Device.objects.create( self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 1', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 1',
site=self.site1,
cluster=self.cluster1
) )
self.device2 = Device.objects.create( self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 2', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 2',
site=self.site1,
cluster=self.cluster1
) )
self.device3 = Device.objects.create( self.device3 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 3', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 3',
site=self.site1,
cluster=self.cluster1
) )
def test_get_device(self): def test_get_device(self):
@@ -1696,6 +1712,8 @@ class DeviceTest(APITestCase):
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.device1.name) self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
def test_list_devices(self): def test_list_devices(self):
@@ -1711,6 +1729,7 @@ class DeviceTest(APITestCase):
'device_role': self.devicerole1.pk, 'device_role': self.devicerole1.pk,
'name': 'Test Device 4', 'name': 'Test Device 4',
'site': self.site1.pk, 'site': self.site1.pk,
'cluster': self.cluster1.pk,
} }
url = reverse('dcim-api:device-list') url = reverse('dcim-api:device-list')
@@ -1722,7 +1741,8 @@ class DeviceTest(APITestCase):
self.assertEqual(device4.device_type_id, data['device_type']) self.assertEqual(device4.device_type_id, data['device_type'])
self.assertEqual(device4.device_role_id, data['device_role']) self.assertEqual(device4.device_role_id, data['device_role'])
self.assertEqual(device4.name, data['name']) self.assertEqual(device4.name, data['name'])
self.assertEqual(device4.site_id, data['site']) self.assertEqual(device4.site.pk, data['site'])
self.assertEqual(device4.cluster.pk, data['cluster'])
def test_create_device_bulk(self): def test_create_device_bulk(self):
@@ -1758,11 +1778,17 @@ class DeviceTest(APITestCase):
def test_update_device(self): def test_update_device(self):
interface = Interface.objects.create(name='Test Interface 1', device=self.device1)
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
data = { data = {
'device_type': self.devicetype2.pk, 'device_type': self.devicetype2.pk,
'device_role': self.devicerole2.pk, 'device_role': self.devicerole2.pk,
'name': 'Test Device X', 'name': 'Test Device X',
'site': self.site2.pk, 'site': self.site2.pk,
'primary_ip4': ip4_address.pk,
'primary_ip6': ip6_address.pk,
} }
url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk})
@@ -1774,7 +1800,9 @@ class DeviceTest(APITestCase):
self.assertEqual(device1.device_type_id, data['device_type']) self.assertEqual(device1.device_type_id, data['device_type'])
self.assertEqual(device1.device_role_id, data['device_role']) self.assertEqual(device1.device_role_id, data['device_role'])
self.assertEqual(device1.name, data['name']) self.assertEqual(device1.name, data['name'])
self.assertEqual(device1.site_id, data['site']) self.assertEqual(device1.site.pk, data['site'])
self.assertEqual(device1.primary_ip4.pk, data['primary_ip4'])
self.assertEqual(device1.primary_ip6.pk, data['primary_ip6'])
def test_delete_device(self): def test_delete_device(self):

View File

@@ -1,9 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from collections import OrderedDict
import importlib import importlib
import inspect import inspect
import pkgutil import pkgutil
from collections import OrderedDict import sys
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -16,19 +17,36 @@ def is_report(obj):
""" """
Returns True if the given object is a Report. Returns True if the given object is a Report.
""" """
if obj in Report.__subclasses__(): return obj in Report.__subclasses__()
return True
return False
def get_report(module_name, report_name): def get_report(module_name, report_name):
""" """
Return a specific report from within a module. Return a specific report from within a module.
""" """
module = importlib.import_module('reports.{}'.format(module_name)) file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
# Python 3.5+
if sys.version_info >= (3, 5):
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except FileNotFoundError:
return None
# Python 2.7
else:
import imp
try:
module = imp.load_source(module_name, file_path)
except IOError:
return None
report = getattr(module, report_name, None) report = getattr(module, report_name, None)
if report is None: if report is None:
return None return None
return report() return report()

View File

@@ -2,7 +2,6 @@ import datetime
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from extras.models import Webhook from extras.models import Webhook
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
@@ -18,23 +17,16 @@ def enqueue_webhooks(instance, action):
if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS: if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
return return
type_create = action == OBJECTCHANGE_ACTION_CREATE # Retrieve any applicable Webhooks
type_update = action == OBJECTCHANGE_ACTION_UPDATE action_flag = {
type_delete = action == OBJECTCHANGE_ACTION_DELETE OBJECTCHANGE_ACTION_CREATE: 'type_create',
OBJECTCHANGE_ACTION_UPDATE: 'type_update',
# Find assigned webhooks OBJECTCHANGE_ACTION_DELETE: 'type_delete',
}[action]
obj_type = ContentType.objects.get_for_model(instance.__class__) obj_type = ContentType.objects.get_for_model(instance.__class__)
webhooks = Webhook.objects.filter( webhooks = Webhook.objects.filter(obj_type=obj_type, enabled=True, **{action_flag: True})
Q(enabled=True) &
(
Q(type_create=type_create) |
Q(type_update=type_update) |
Q(type_delete=type_delete)
) &
Q(obj_type=obj_type)
)
if webhooks: if webhooks.exists():
# Get the Model's API serializer class and serialize the object # Get the Model's API serializer class and serialize the object
serializer_class = get_serializer_for_model(instance.__class__) serializer_class = get_serializer_for_model(instance.__class__)
serializer_context = { serializer_context = {

View File

@@ -37,8 +37,12 @@ def process_webhook(webhook, data, model_class, event, timestamp):
prepared_request = requests.Request(**params).prepare() prepared_request = requests.Request(**params).prepare()
if webhook.secret != '': if webhook.secret != '':
# sign the request with the secret # Sign the request with a hash of the secret key and its content.
hmac_prep = hmac.new(bytearray(webhook.secret, 'utf8'), prepared_request.body, digestmod=hashlib.sha512) hmac_prep = hmac.new(
key=webhook.secret.encode('utf8'),
msg=prepared_request.body.encode('utf8'),
digestmod=hashlib.sha512
)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest() prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
with requests.Session() as session: with requests.Session() as session:

View File

@@ -258,7 +258,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.4.0' VERSION = '2.4.2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@@ -553,7 +553,7 @@
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'dcim:interface_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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}

View File

@@ -9,12 +9,12 @@
<div class="panel-footer"> <div class="panel-footer">
{% if table.rows %} {% if table.rows %}
{% if edit_url %} {% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button> </button>
{% endif %} {% endif %}
{% if delete_url %} {% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}" class="btn btn-xs btn-danger"> <button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button> </button>
{% endif %} {% endif %}

View File

@@ -111,7 +111,7 @@
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}" class="btn btn-info btn-xs" title="Edit interface"> <a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -194,10 +194,13 @@
</small> </small>
</div> </div>
{% endwith %} {% endwith %}
{% empty %} {% if forloop.last %}
<div class="list-group-item"> <div class="list-group-item text-right">
Welcome to NetBox! {% if perms.add_site %} <a href="{% url 'dcim:site_add' %}">Add a site</a> to get started.{% endif %} <a href="{% url 'extras:objectchange_list' %}">View All Changes</a>
</div> </div>
{% endif %}
{% empty %}
<div class="list-group-item text-muted">No change history found</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
{% with custom_fields=obj.custom_fields %} {% with custom_fields=obj.get_custom_fields %}
{% if custom_fields %} {% if custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

View File

@@ -40,7 +40,7 @@
{% include 'inc/created_updated.html' with obj=vrf %} {% include 'inc/created_updated.html' with obj=vrf %}
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ aggregate.get_absolute_url }}">VRF</a> <a href="{{ vrf.get_absolute_url }}">VRF</a>
</li> </li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a> <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>

View File

@@ -282,12 +282,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}" class="btn btn-warning btn-xs"> <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}

View File

@@ -74,6 +74,12 @@ class ChoiceField(Field):
return {'value': obj, 'label': self._choices[obj]} return {'value': obj, 'label': self._choices[obj]}
def to_internal_value(self, data): def to_internal_value(self, data):
# Hotwiring boolean values
if hasattr(data, 'lower'):
if data.lower() == 'true':
return True
if data.lower() == 'false':
return False
return data return data

View File

@@ -101,8 +101,8 @@ def serialize_object(obj, extra=None):
} }
# Include any tags # Include any tags
# if hasattr(obj, 'tags'): if hasattr(obj, 'tags'):
# data['tags'] = [tag.name for tag in obj.tags.all()] data['tags'] = [tag.name for tag in obj.tags.all()]
# Append any extra data # Append any extra data
if extra is not None: if extra is not None:

View File

@@ -82,7 +82,7 @@ class NestedClusterSerializer(WritableNestedSerializer):
# #
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
@@ -146,7 +146,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),

View File

@@ -1,11 +1,12 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import IPAddress, VLAN
from utilities.testing import APITestCase from utilities.testing import APITestCase
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -367,6 +368,10 @@ class VirtualMachineTest(APITestCase):
def test_update_virtualmachine(self): def test_update_virtualmachine(self):
interface = Interface.objects.create(name='Test Interface 1', virtual_machine=self.virtualmachine1)
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
cluster2 = Cluster.objects.create( cluster2 = Cluster.objects.create(
name='Test Cluster 2', name='Test Cluster 2',
type=ClusterType.objects.first(), type=ClusterType.objects.first(),
@@ -375,6 +380,8 @@ class VirtualMachineTest(APITestCase):
data = { data = {
'name': 'Test Virtual Machine X', 'name': 'Test Virtual Machine X',
'cluster': cluster2.pk, 'cluster': cluster2.pk,
'primary_ip4': ip4_address.pk,
'primary_ip6': ip6_address.pk,
} }
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
@@ -385,6 +392,8 @@ class VirtualMachineTest(APITestCase):
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine1.name, data['name']) self.assertEqual(virtualmachine1.name, data['name'])
self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
self.assertEqual(virtualmachine1.primary_ip4.pk, data['primary_ip4'])
self.assertEqual(virtualmachine1.primary_ip6.pk, data['primary_ip6'])
def test_delete_virtualmachine(self): def test_delete_virtualmachine(self):