Merge branch 'develop' into 2237-floating-buttons

This commit is contained in:
Jeremy Stretch 2018-08-13 10:51:06 -04:00
commit 2a6e0c178f
18 changed files with 219 additions and 40 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

@ -120,7 +120,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer() 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) 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, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
@ -666,7 +666,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) 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) tags = TagListSerializerField(required=False)
class Meta: class Meta:

View File

@ -1795,7 +1795,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
# Compile VLAN choices # Compile VLAN choices
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) global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans]) '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]) (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
) )
parent = self.instance.parent site = getattr(self.instance.parent, 'site', None)
if parent is not None: if site is not None:
# Add site VLANs # Add non-grouped site VLANs
if parent.site: site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped 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) site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'{} / {}'.format(group.site.name, group.name), '{} / {}'.format(group.site.name, group.name),

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

@ -138,8 +138,11 @@ class ImageAttachmentViewSet(ModelViewSet):
# #
class ConfigContextViewSet(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 serializer_class = serializers.ConfigContextSerializer
filter_class = filters.ConfigContextFilter
# #

View File

@ -6,9 +6,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from taggit.models import Tag 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 .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): class CustomFieldFilter(django_filters.Filter):
@ -124,6 +125,92 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug'] 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): class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField from taggit.forms import TagField
from taggit.models import Tag from taggit.models import Tag
from dcim.models import Region from dcim.models import DeviceRole, Platform, Region, Site
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
JSONField, SlugField,
)
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES, 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 # Image attachments
# #

View File

@ -72,15 +72,10 @@ class ConfigContextTable(BaseTable):
is_active = BooleanColumn( is_active = BooleanColumn(
verbose_name='Active' verbose_name='Active'
) )
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConfigContext model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') fields = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable): class ObjectChangeTable(BaseTable):

View File

@ -14,7 +14,7 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters 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 .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable from .tables import ConfigContextTable, ObjectChangeTable, TagTable
@ -56,6 +56,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConfigContextListView(ObjectListView): class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
table = ConfigContextTable table = ConfigContextTable
template_name = 'extras/configcontext_list.html' template_name = 'extras/configcontext_list.html'

View File

@ -140,10 +140,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
available_prefixes.remove(allocated_prefix) available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested # Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True) serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else: else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0]) serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es) # Create the new Prefix(es)
if serializer.is_valid(): if serializer.is_valid():
@ -199,10 +200,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None 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 # Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True) serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else: else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0]) serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es) # Create the new IP address(es)
if serializer.is_valid(): if serializer.is_valid():

View File

@ -494,7 +494,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_prefix(self): 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}) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests # Create four available prefixes with individual requests
@ -512,6 +513,7 @@ class PrefixTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i]) 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']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix # Try to create one more prefix
@ -562,7 +564,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_ip(self): 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}) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests # 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) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP # Try to create one more IP

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.4.2-dev' VERSION = '2.4.4-dev'
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

@ -573,7 +573,7 @@
{% endif %} {% endif %}
{% if cs_ports or device.device_type.is_console_server %} {% if cs_ports or device.device_type.is_console_server %}
{% if perms.dcim.delete_consoleserverport %} {% if perms.dcim.delete_consoleserverport %}
<form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <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"> <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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
@ -631,7 +631,7 @@
{% endif %} {% endif %}
{% if power_outlets or device.device_type.is_pdu %} {% if power_outlets or device.device_type.is_pdu %}
{% if perms.dcim.delete_poweroutlet %} {% if perms.dcim.delete_poweroutlet %}
<form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <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"> <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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}

View File

@ -140,6 +140,20 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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> <tr>
<td>Tenants</td> <td>Tenants</td>
<td> <td>

View File

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

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

@ -170,7 +170,9 @@ class WritableNestedSerializer(ModelSerializer):
if data is None: if data is None:
return None return None
try: 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: except ObjectDoesNotExist:
raise ValidationError("Invalid ID") raise ValidationError("Invalid ID")

View File

@ -260,6 +260,22 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk]) 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): def to_csv(self):
return ( return (
self.name, self.name,