Compare commits

..

62 Commits

Author SHA1 Message Date
Jeremy Stretch
c4f7e8121a Merge pull request #1903 from digitalocean/develop
Release v2.2.10
2018-02-21 16:05:45 -05:00
Jeremy Stretch
8b5dba25f5 Release v2.2.10 2018-02-21 16:04:15 -05:00
Jeremy Stretch
a5dc9537e5 Closes #1693: Allow specifying loose or exact matching for custom field filters 2018-02-21 15:40:11 -05:00
Jeremy Stretch
3064948d8c Closes #1801: Update list of rack groups when selecting a site to filter by in rack elevations list 2018-02-21 14:06:38 -05:00
John Eismeier
e6bcc4a3fe Propose fix typos (#1897) 2018-02-21 12:39:29 -05:00
Jeremy Stretch
6967b6bdc5 Fixes #1892: Removed convenience function from an old migration (see #632) to fix database error on extras/0009_topologymap_type 2018-02-21 12:00:38 -05:00
Jeremy Stretch
a8977a5dec Closes #1885: Added a device filter field for primary IP 2018-02-21 10:55:49 -05:00
Jeremy Stretch
b837e8ea0b Fixes #1886: Allow setting the primary IPv4/v6 address for a VirtualMachine via the web UI 2018-02-21 10:49:40 -05:00
Jeremy Stretch
110052fa0f Fixes #1889: Consistent ordering of interface fields on add/edit 2018-02-21 10:38:45 -05:00
Jeremy Stretch
2d93c2b2da Closes #78: Implemented ability to render topology maps for console/power 2018-02-15 12:10:29 -05:00
Jeremy Stretch
9e4f2a9614 Fixed panel heading CSS class 2018-02-15 10:01:02 -05:00
Jeremy Stretch
86b0491b68 Closes #1876: Added explanatory title text to disabled NAPALM buttons on device view 2018-02-13 11:03:31 -05:00
Jeremy Stretch
c8309581be Fixes #1869: Corrected ordering of VRFs with duplicate names 2018-02-07 13:40:08 -05:00
Jeremy Stretch
376c531fe4 Template libraries cleanup 2018-02-07 13:35:19 -05:00
Jeremy Stretch
6436d703f5 Merge pull request #1852 from digitalocean/develop
Release v2.2.9
2018-01-31 10:43:20 -05:00
Jeremy Stretch
ec0cb7a8bc Merge pull request #1789 from digitalocean/develop
Release v2.2.8
2017-12-20 15:27:22 -05:00
Jeremy Stretch
e98f0c39d1 Merge pull request #1757 from digitalocean/develop
Release v2.2.7
2017-12-07 14:52:28 -05:00
Jeremy Stretch
50a451eddc Merge pull request #1720 from digitalocean/develop
Release v2.2.6
2017-11-16 12:00:34 -05:00
Jeremy Stretch
a5a7358d26 Merge pull request #1708 from digitalocean/develop
Release v2.2.5
2017-11-14 13:25:11 -05:00
Jeremy Stretch
f9452163c5 Merge pull request #1671 from digitalocean/develop
Release v2.2.4
2017-10-31 15:21:23 -04:00
Jeremy Stretch
3067c3f262 Merge pull request #1668 from digitalocean/develop
Release v2.2.3
2017-10-31 14:02:15 -04:00
Jeremy Stretch
7a64404299 Merge pull request #1614 from digitalocean/develop
Release v2.2.2
2017-10-17 11:24:02 -04:00
Jeremy Stretch
2bda399982 Merge pull request #1577 from digitalocean/develop
Release v2.2.1
2017-10-12 16:11:17 -04:00
Jeremy Stretch
74731bc6ae Merge pull request #1575 from digitalocean/develop
Release v2.2.0
2017-10-12 14:01:28 -04:00
Jeremy Stretch
7cb287d6c6 Merge pull request #1572 from digitalocean/develop
Release v2.1.6
2017-10-11 13:02:32 -04:00
Jeremy Stretch
aa8f734bd1 Merge pull request #1537 from digitalocean/develop
Release v2.1.5
2017-09-25 14:52:43 -04:00
Jeremy Stretch
f6d1163ddd Merge pull request #1461 from digitalocean/develop
Release v2.1.4
2017-08-30 14:43:01 -04:00
Jeremy Stretch
5be30bd278 Merge pull request #1428 from digitalocean/develop
Release v2.1.3
2017-08-15 15:52:34 -04:00
Jeremy Stretch
fa7b7288c9 Merge pull request #1398 from digitalocean/develop
Release v2.1.2
2017-08-04 10:54:29 -04:00
Jeremy Stretch
9cc03aaa9a Merge pull request #1387 from digitalocean/develop
Release v2.1.1
2017-08-02 14:22:30 -04:00
Jeremy Stretch
1bda56ea23 Merge pull request #1372 from digitalocean/develop
Release v2.1.0
2017-07-25 11:21:44 -04:00
Jeremy Stretch
64a34ced72 Merge pull request #1346 from digitalocean/develop
Release v2.0.10
2017-07-14 10:09:16 -04:00
Jeremy Stretch
e05d379101 Merge pull request #1327 from digitalocean/develop
Release v2.0.9
2017-07-10 09:43:59 -04:00
Jeremy Stretch
a355783377 Merge pull request #1316 from digitalocean/develop
Release v2.0.8
2017-07-05 14:36:08 -04:00
Jeremy Stretch
88239e0b0d Merge pull request #1278 from digitalocean/develop
Release v2.0.7
2017-06-15 14:26:38 -04:00
Jeremy Stretch
5c63a499d5 Merge pull request #1259 from digitalocean/develop
Release v2.0.6
2017-06-12 09:51:15 -04:00
Jeremy Stretch
50496b1a59 Merge pull request #1251 from digitalocean/develop
Release v2.0.5
2017-06-08 10:10:41 -04:00
Jeremy Stretch
f7b0d22f86 Merge pull request #1230 from digitalocean/develop
Release v2.0.4
2017-05-25 14:45:13 -04:00
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
43e1e0dbc8 Merge pull request #1181 from digitalocean/develop
Release v2.0.2
2017-05-15 13:23:33 -04:00
Jeremy Stretch
f731900e2f Merge pull request #1154 from digitalocean/develop
Release v2.0.1
2017-05-09 22:47:52 -04:00
Jeremy Stretch
b1bcaa33e7 Merge pull request #1148 from digitalocean/develop
Release v2.0.0
2017-05-09 15:09:28 -04:00
Jeremy Stretch
17873706b7 Merge pull request #1094 from digitalocean/develop
Release v1.9.6
2017-04-21 14:52:53 -04:00
Jeremy Stretch
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -04:00
Jeremy Stretch
f89d91783b Merge pull request #1035 from digitalocean/develop
Release v1.9.4-r1
2017-04-04 15:50:28 -04:00
Jeremy Stretch
3ffe36e5ed Merge pull request #1032 from digitalocean/develop
Release v1.9.4
2017-04-04 12:01:58 -04:00
Jeremy Stretch
be393a9d10 Merge pull request #989 from digitalocean/develop
Release v1.9.3
2017-03-23 16:27:06 -04:00
Jeremy Stretch
27eefd8705 Merge pull request #966 from digitalocean/develop
Release v1.9.2
2017-03-14 17:14:19 -04:00
Jeremy Stretch
097e0f38ff Merge pull request #949 from digitalocean/develop
Release v1.9.1
2017-03-08 14:40:16 -05:00
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
57ddd5086f Merge pull request #666 from digitalocean/develop
Release v1.7.0
2016-11-03 15:12:33 -04:00
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
127 changed files with 1481 additions and 4558 deletions

View File

@@ -5,7 +5,7 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects
* `POST`: Create a new object
* `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifiying the field to be changed
* `PATCH`: Updates an existing object, only specifying the field to be changed
* `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header:
@@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
* Closing connection 0
```
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.

View File

@@ -112,11 +112,3 @@ Console ports connect only to console server ports, and power ports connect only
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
---
# Virtual Chassis
A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.

View File

@@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
# heirarchy.
# hierarchy.
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
"(objectClass=group)")
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

View File

@@ -1,4 +1,4 @@
NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
```
./manage.py nbshell
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
982
```
Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
```
>>> Device.objects.filter(tenant__name='Pied Piper')

View File

@@ -2,12 +2,11 @@ from __future__ import unicode_literals
from rest_framework import serializers
from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from utilities.api import ValidatedModelSerializer
#
@@ -20,7 +19,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
model = Provider
fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
@@ -38,7 +37,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
model = Provider
fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
@@ -67,15 +66,14 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
class CircuitSerializer(CustomFieldModelSerializer):
provider = NestedProviderSerializer()
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer()
class Meta:
model = Circuit
fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'custom_fields', 'created', 'last_updated',
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'custom_fields',
]
@@ -92,8 +90,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
class Meta:
model = Circuit
fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'custom_fields', 'created', 'last_updated',
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'custom_fields',
]

View File

@@ -3,13 +3,14 @@ from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.api import FieldChoicesViewSet, ModelViewSet
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
@@ -27,7 +28,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
# Providers
#
class ProviderViewSet(CustomFieldModelViewSet):
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Provider.objects.all()
serializer_class = serializers.ProviderSerializer
write_serializer_class = serializers.WritableProviderSerializer
@@ -58,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet):
# Circuits
#
class CircuitViewSet(CustomFieldModelViewSet):
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
serializer_class = serializers.CircuitSerializer
write_serializer_class = serializers.WritableCircuitSerializer
@@ -69,7 +70,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
# Circuit Terminations
#
class CircuitTerminationViewSet(ModelViewSet):
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
serializer_class = serializers.CircuitTerminationSerializer
write_serializer_class = serializers.WritableCircuitTerminationSerializer

View File

@@ -1,22 +1,6 @@
from __future__ import unicode_literals
# Circuit statuses
CIRCUIT_STATUS_DEPROVISIONING = 0
CIRCUIT_STATUS_ACTIVE = 1
CIRCUIT_STATUS_PLANNED = 2
CIRCUIT_STATUS_PROVISIONING = 3
CIRCUIT_STATUS_OFFLINE = 4
CIRCUIT_STATUS_DECOMMISSIONED = 5
CIRCUIT_STATUS_CHOICES = [
[CIRCUIT_STATUS_PLANNED, 'Planned'],
[CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
[CIRCUIT_STATUS_ACTIVE, 'Active'],
[CIRCUIT_STATUS_OFFLINE, 'Offline'],
[CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
[CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
]
# CircuitTermination sides
TERM_SIDE_A = 'A'
TERM_SIDE_Z = 'Z'

View File

@@ -7,7 +7,6 @@ from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -78,10 +77,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Circuit type (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=CIRCUIT_STATUS_CHOICES,
null_value=None
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',

View File

@@ -8,10 +8,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
SmallTextarea, SlugField,
)
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -106,7 +105,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments',
]
help_texts = {
@@ -133,11 +132,6 @@ class CircuitCSVForm(forms.ModelForm):
'invalid_choice': 'Invalid circuit type.'
}
)
status = CSVChoiceField(
choices=CIRCUIT_STATUS_CHOICES,
required=False,
help_text='Operational status'
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
@@ -150,16 +144,13 @@ class CircuitCSVForm(forms.ModelForm):
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
description = forms.CharField(max_length=100, required=False)
@@ -169,13 +160,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
def circuit_status_choices():
status_counts = {}
for status in Circuit.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 CIRCUIT_STATUS_CHOICES]
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
q = forms.CharField(required=False, label='Search')
@@ -187,7 +171,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug',

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-06 18:48
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0009_unicode_literals'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='status',
field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1),
),
]

View File

@@ -5,12 +5,11 @@ from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
from .constants import *
@python_2_unicode_compatible
@@ -90,7 +89,6 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
@@ -98,9 +96,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
class Meta:
ordering = ['provider', 'cid']
@@ -117,7 +113,6 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
self.cid,
self.provider.name,
self.type.name,
self.get_status_display(),
self.tenant.name if self.tenant else None,
self.install_date,
self.commit_rate,
@@ -125,9 +120,6 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
self.comments,
)
def get_status_class(self):
return STATUS_CLASSES[self.status]
def _get_termination(self, side):
for ct in self.terminations.all():
if ct.term_side == side:

View File

@@ -14,10 +14,6 @@ CIRCUITTYPE_ACTIONS = """
{% endif %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
class CircuitTerminationColumn(tables.Column):
@@ -80,11 +76,10 @@ class CircuitTable(BaseTable):
pk = ToggleColumn()
cid = tables.LinkColumn(verbose_name='ID')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')

View File

@@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.objects.count(), 4)
@@ -77,32 +77,6 @@ class ProviderTest(HttpStatusMixin, APITestCase):
self.assertEqual(provider4.name, data['name'])
self.assertEqual(provider4.slug, data['slug'])
def test_create_provider_bulk(self):
data = [
{
'name': 'Test Provider 4',
'slug': 'test-provider-4',
},
{
'name': 'Test Provider 5',
'slug': 'test-provider-5',
},
{
'name': 'Test Provider 6',
'slug': 'test-provider-6',
},
]
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.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_provider(self):
data = {
@@ -111,7 +85,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Provider.objects.count(), 3)
@@ -162,7 +136,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuittype-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitType.objects.count(), 4)
@@ -178,7 +152,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitType.objects.count(), 3)
@@ -234,7 +208,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 4)
@@ -243,35 +217,6 @@ class CircuitTest(HttpStatusMixin, APITestCase):
self.assertEqual(circuit4.provider_id, data['provider'])
self.assertEqual(circuit4.type_id, data['type'])
def test_create_circuit_bulk(self):
data = [
{
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
},
{
'cid': 'TEST0005',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
},
{
'cid': 'TEST0006',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
},
]
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 6)
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
def test_update_circuit(self):
data = {
@@ -281,7 +226,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Circuit.objects.count(), 3)
@@ -348,7 +293,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuittermination-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitTermination.objects.count(), 4)
@@ -368,7 +313,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
}
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitTermination.objects.count(), 3)

View File

@@ -7,20 +7,19 @@ from rest_framework.validators import UniqueTogetherValidator
from circuits.models import Circuit, CircuitTermination
from dcim.constants import (
CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
)
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis,
RackReservation, RackRole, Region, Site,
)
from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN
from ipam.models import IPAddress
from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.models import Cluster
@@ -56,18 +55,15 @@ class WritableRegionSerializer(ValidatedModelSerializer):
#
class SiteSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
region = NestedRegionSerializer()
tenant = NestedTenantSerializer()
time_zone = TimeZoneField(required=False)
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
'count_circuits',
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
]
@@ -80,14 +76,12 @@ class NestedSiteSerializer(serializers.ModelSerializer):
class WritableSiteSerializer(CustomFieldModelSerializer):
time_zone = TimeZoneField(required=False)
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'custom_fields', 'created', 'last_updated',
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
]
@@ -153,7 +147,7 @@ class RackSerializer(CustomFieldModelSerializer):
model = Rack
fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
'u_height', 'desc_units', 'comments', 'custom_fields',
]
@@ -171,7 +165,7 @@ class WritableRackSerializer(CustomFieldModelSerializer):
model = Rack
fields = [
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
'desc_units', 'comments', 'custom_fields',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
# prevents facility_id from being interpreted as a required field.
@@ -221,12 +215,10 @@ class RackUnitSerializer(serializers.Serializer):
class RackReservationSerializer(serializers.ModelSerializer):
rack = NestedRackSerializer()
user = NestedUserSerializer()
tenant = NestedTenantSerializer()
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
class WritableRackReservationSerializer(ValidatedModelSerializer):
@@ -431,12 +423,11 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
# Platforms
#
class PlatformSerializer(serializers.ModelSerializer):
manufacturer = NestedManufacturerSerializer()
class PlatformSerializer(ValidatedModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
class NestedPlatformSerializer(serializers.ModelSerializer):
@@ -447,13 +438,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug']
class WritablePlatformSerializer(ValidatedModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
#
# Devices
#
@@ -476,16 +460,6 @@ class NestedClusterSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name']
# Cannot import NestedVirtualChassisSerializer due to circular dependency
class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'master']
class DeviceSerializer(CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
@@ -494,21 +468,19 @@ class DeviceSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer()
rack = NestedRackSerializer()
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
primary_ip = DeviceIPAddressSerializer()
primary_ip4 = DeviceIPAddressSerializer()
primary_ip6 = DeviceIPAddressSerializer()
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer()
virtual_chassis = DeviceVirtualChassisSerializer()
class Meta:
model = Device
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
'last_updated',
'cluster', 'comments', 'custom_fields',
]
def get_parent_device(self, obj):
@@ -528,8 +500,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
model = Device
fields = [
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
]
validators = []
@@ -657,15 +628,6 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
]
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
class InterfaceVLANSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@@ -673,15 +635,12 @@ class InterfaceSerializer(serializers.ModelSerializer):
is_connected = serializers.SerializerMethodField(read_only=True)
interface_connection = serializers.SerializerMethodField(read_only=True)
circuit_termination = InterfaceCircuitTerminationSerializer()
untagged_vlan = InterfaceVLANSerializer()
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
tagged_vlans = InterfaceVLANSerializer(many=True)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
'is_connected', 'interface_connection', 'circuit_termination',
]
def get_is_connected(self, obj):
@@ -726,23 +685,8 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans',
]
def validate(self, data):
# Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
# VirtualMachine, or are global.
parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [parent, None]:
raise serializers.ValidationError(
"Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
"global".format(vlan)
)
return super(WritableInterfaceSerializer, self).validate(data)
#
# Device bays
@@ -827,30 +771,3 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceConnection
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
#
# Virtual chassis
#
class VirtualChassisSerializer(serializers.ModelSerializer):
master = NestedDeviceSerializer()
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain']
class NestedVirtualChassisSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
class Meta:
model = VirtualChassis
fields = ['id', 'url']
class WritableVirtualChassisSerializer(ValidatedModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain']

View File

@@ -60,9 +60,6 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
# Virtual chassis
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
# Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')

View File

@@ -3,25 +3,26 @@ from __future__ import unicode_literals
from collections import OrderedDict
from django.conf import settings
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route
from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ViewSet
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
from dcim import filters
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis,
RackReservation, RackRole, Region, Site,
)
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
from utilities.api import (
IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin,
)
from . import serializers
from .exceptions import MissingFilterException
@@ -46,7 +47,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
# Regions
#
class RegionViewSet(ModelViewSet):
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
write_serializer_class = serializers.WritableRegionSerializer
@@ -57,7 +58,7 @@ class RegionViewSet(ModelViewSet):
# Sites
#
class SiteViewSet(CustomFieldModelViewSet):
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Site.objects.select_related('region', 'tenant')
serializer_class = serializers.SiteSerializer
write_serializer_class = serializers.WritableSiteSerializer
@@ -78,7 +79,7 @@ class SiteViewSet(CustomFieldModelViewSet):
# Rack groups
#
class RackGroupViewSet(ModelViewSet):
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
queryset = RackGroup.objects.select_related('site')
serializer_class = serializers.RackGroupSerializer
write_serializer_class = serializers.WritableRackGroupSerializer
@@ -99,7 +100,7 @@ class RackRoleViewSet(ModelViewSet):
# Racks
#
class RackViewSet(CustomFieldModelViewSet):
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
serializer_class = serializers.RackSerializer
write_serializer_class = serializers.WritableRackSerializer
@@ -130,8 +131,8 @@ class RackViewSet(CustomFieldModelViewSet):
# Rack reservations
#
class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
queryset = RackReservation.objects.select_related('rack')
serializer_class = serializers.RackReservationSerializer
write_serializer_class = serializers.WritableRackReservationSerializer
filter_class = filters.RackReservationFilter
@@ -155,7 +156,7 @@ class ManufacturerViewSet(ModelViewSet):
# Device types
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = serializers.DeviceTypeSerializer
write_serializer_class = serializers.WritableDeviceTypeSerializer
@@ -166,42 +167,42 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
# Device type components
#
class ConsolePortTemplateViewSet(ModelViewSet):
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.ConsolePortTemplateSerializer
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
filter_class = filters.ConsolePortTemplateFilter
class ConsoleServerPortTemplateViewSet(ModelViewSet):
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.ConsoleServerPortTemplateSerializer
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
filter_class = filters.ConsoleServerPortTemplateFilter
class PowerPortTemplateViewSet(ModelViewSet):
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.PowerPortTemplateSerializer
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
filter_class = filters.PowerPortTemplateFilter
class PowerOutletTemplateViewSet(ModelViewSet):
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.PowerOutletTemplateSerializer
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
filter_class = filters.PowerOutletTemplateFilter
class InterfaceTemplateViewSet(ModelViewSet):
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.InterfaceTemplateSerializer
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
filter_class = filters.InterfaceTemplateFilter
class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.DeviceBayTemplateSerializer
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
@@ -225,7 +226,6 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.all()
serializer_class = serializers.PlatformSerializer
write_serializer_class = serializers.WritablePlatformSerializer
filter_class = filters.PlatformFilter
@@ -233,10 +233,9 @@ class PlatformViewSet(ModelViewSet):
# Devices
#
class DeviceViewSet(CustomFieldModelViewSet):
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Device.objects.select_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master',
).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
)
@@ -264,7 +263,12 @@ class DeviceViewSet(CustomFieldModelViewSet):
import napalm
except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
# TODO: Remove support for NAPALM < 2.0
try:
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
except ImportError:
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
# Validate the configured driver
try:
@@ -312,35 +316,35 @@ class DeviceViewSet(CustomFieldModelViewSet):
# Device components
#
class ConsolePortViewSet(ModelViewSet):
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
serializer_class = serializers.ConsolePortSerializer
write_serializer_class = serializers.WritableConsolePortSerializer
filter_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(ModelViewSet):
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
serializer_class = serializers.ConsoleServerPortSerializer
write_serializer_class = serializers.WritableConsoleServerPortSerializer
filter_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(ModelViewSet):
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
serializer_class = serializers.PowerPortSerializer
write_serializer_class = serializers.WritablePowerPortSerializer
filter_class = filters.PowerPortFilter
class PowerOutletViewSet(ModelViewSet):
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
serializer_class = serializers.PowerOutletSerializer
write_serializer_class = serializers.WritablePowerOutletSerializer
filter_class = filters.PowerOutletFilter
class InterfaceViewSet(ModelViewSet):
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Interface.objects.select_related('device')
serializer_class = serializers.InterfaceSerializer
write_serializer_class = serializers.WritableInterfaceSerializer
@@ -357,14 +361,14 @@ class InterfaceViewSet(ModelViewSet):
return Response(serializer.data)
class DeviceBayViewSet(ModelViewSet):
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device')
serializer_class = serializers.DeviceBaySerializer
write_serializer_class = serializers.WritableDeviceBaySerializer
filter_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet):
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
serializer_class = serializers.InventoryItemSerializer
write_serializer_class = serializers.WritableInventoryItemSerializer
@@ -387,23 +391,13 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
filter_class = filters.PowerConnectionFilter
class InterfaceConnectionViewSet(ModelViewSet):
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
serializer_class = serializers.InterfaceConnectionSerializer
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
filter_class = filters.InterfaceConnectionFilter
#
# Virtual chassis
#
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.all()
serializer_class = serializers.VirtualChassisSerializer
write_serializer_class = serializers.WritableVirtualChassisSerializer
#
# Miscellaneous
#

View File

@@ -6,6 +6,3 @@ from django.apps import AppConfig
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
import dcim.signals

View File

@@ -193,43 +193,24 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
IFACE_MODE_ACCESS = 100
IFACE_MODE_TAGGED = 200
IFACE_MODE_TAGGED_ALL = 300
IFACE_MODE_CHOICES = [
[IFACE_MODE_ACCESS, 'Access'],
[IFACE_MODE_TAGGED, 'Tagged'],
[IFACE_MODE_TAGGED_ALL, 'Tagged All'],
]
# Device statuses
DEVICE_STATUS_OFFLINE = 0
DEVICE_STATUS_ACTIVE = 1
DEVICE_STATUS_PLANNED = 2
DEVICE_STATUS_STAGED = 3
DEVICE_STATUS_FAILED = 4
DEVICE_STATUS_INVENTORY = 5
DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_ACTIVE, 'Active'],
[DEVICE_STATUS_OFFLINE, 'Offline'],
[DEVICE_STATUS_PLANNED, 'Planned'],
[DEVICE_STATUS_STAGED, 'Staged'],
[DEVICE_STATUS_FAILED, 'Failed'],
[DEVICE_STATUS_INVENTORY, 'Inventory'],
STATUS_OFFLINE = 0
STATUS_ACTIVE = 1
STATUS_PLANNED = 2
STATUS_STAGED = 3
STATUS_FAILED = 4
STATUS_INVENTORY = 5
STATUS_CHOICES = [
[STATUS_ACTIVE, 'Active'],
[STATUS_OFFLINE, 'Offline'],
[STATUS_PLANNED, 'Planned'],
[STATUS_STAGED, 'Staged'],
[STATUS_FAILED, 'Failed'],
[STATUS_INVENTORY, 'Inventory'],
]
# Site statuses
SITE_STATUS_ACTIVE = 1
SITE_STATUS_PLANNED = 2
SITE_STATUS_RETIRED = 4
SITE_STATUS_CHOICES = [
[SITE_STATUS_ACTIVE, 'Active'],
[SITE_STATUS_PLANNED, 'Planned'],
[SITE_STATUS_RETIRED, 'Retired'],
]
# Bootstrap CSS classes for device statuses
STATUS_CLASSES = {
# Bootstrap CSS classes for device stasuses
DEVICE_STATUS_CLASSES = {
0: 'warning',
1: 'success',
2: 'info',

View File

@@ -11,14 +11,13 @@ from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NumericInFilter
from virtualization.models import Cluster
from .constants import (
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
WIRELESS_IFACE_TYPES,
IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, STATUS_CHOICES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
)
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis,
RackReservation, RackRole, Region, Site,
)
@@ -58,10 +57,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
status = django_filters.MultipleChoiceFilter(
choices=SITE_STATUS_CHOICES,
null_value=None
)
region_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Region (ID)',
@@ -93,7 +88,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
qs_filter = (
Q(name__icontains=value) |
Q(facility__icontains=value) |
Q(description__icontains=value) |
Q(physical_address__icontains=value) |
Q(shipping_address__icontains=value) |
Q(contact_name__icontains=value) |
@@ -227,16 +221,6 @@ class RackReservationFilter(django_filters.FilterSet):
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label='User (ID)',
@@ -363,17 +347,6 @@ class DeviceRoleFilter(django_filters.FilterSet):
class PlatformFilter(django_filters.FilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
class Meta:
model = Platform
@@ -465,7 +438,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Device model (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=DEVICE_STATUS_CHOICES,
choices=STATUS_CHOICES,
null_value=None
)
is_full_depth = django_filters.BooleanFilter(
@@ -492,11 +465,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='_has_primary_ip',
label='Has a primary IP',
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
name='virtual_chassis',
queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)',
)
class Meta:
model = Device
@@ -612,9 +580,8 @@ class InterfaceFilter(django_filters.FilterSet):
def filter_device(self, queryset, name, value):
try:
device = Device.objects.select_related('device_type').get(**{name: value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
ordering = device.device_type.interface_ordering
return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
return queryset.filter(device=device).order_naturally(ordering)
except Device.DoesNotExist:
return queryset.none()
@@ -683,13 +650,6 @@ class InventoryItemFilter(DeviceComponentFilterSet):
return queryset.filter(qs_filter)
class VirtualChassisFilter(django_filters.FilterSet):
class Meta:
model = VirtualChassis
fields = ['domain']
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter(
method='filter_site',

View File

@@ -7,32 +7,29 @@ from django.contrib.auth.models import User
from django.contrib.postgres.forms.array import SimpleArrayField
from django.db.models import Count, Q
from mptt.forms import TreeNodeChoiceField
from timezone_field import TimeZoneFormField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress, VLAN, VLANGroup
from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField, FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .constants import (
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES,
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, STATUS_CHOICES,
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
)
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
RackRole, Region, Site, VirtualChassis
RackRole, Region, Site,
)
DEVICE_BY_PK_RE = '{\d+\}'
@@ -50,14 +47,6 @@ def get_device_by_name_or_pk(name):
return device
class BulkRenameForm(forms.Form):
"""
An extendable form to be used for renaming device components in bulk.
"""
find = forms.CharField()
replace = forms.CharField()
#
# Regions
#
@@ -107,9 +96,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = Site
fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
'comments',
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -125,11 +113,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class SiteCSVForm(forms.ModelForm):
status = CSVChoiceField(
choices=DEVICE_STATUS_CHOICES,
required=False,
help_text='Operational status'
)
region = forms.ModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -161,28 +144,17 @@ class SiteCSVForm(forms.ModelForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
description = forms.CharField(max_length=100, required=False)
time_zone = TimeZoneFormField(required=False)
class Meta:
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
def site_status_choices():
status_counts = {}
for status in Site.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 SITE_STATUS_CHOICES]
nullable_fields = ['region', 'tenant', 'asn']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
q = forms.CharField(required=False, label='Search')
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')),
to_field_name='slug',
@@ -400,13 +372,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Rack reservations
#
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
class RackReservationForm(BootstrapMixin, forms.ModelForm):
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
class Meta:
model = RackReservation
fields = ['units', 'user', 'tenant_group', 'tenant', 'description']
fields = ['units', 'user', 'description']
def __init__(self, *args, **kwargs):
@@ -436,17 +408,11 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
label='Rack group',
null_label='-- None --'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
to_field_name='slug',
null_label='-- None --'
)
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
@@ -695,7 +661,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Platform
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
class PlatformCSVForm(forms.ModelForm):
@@ -706,7 +672,6 @@ class PlatformCSVForm(forms.ModelForm):
fields = Platform.csv_headers
help_texts = {
'name': 'Platform name',
'manufacturer': 'Manufacturer name',
}
@@ -792,35 +757,32 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = [(None, '---------')]
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
interface_ids = self.instance.vc_interfaces.values('pk')
# Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter(
family=family, interface_id__in=interface_ids
family=family, interface__device=self.instance
)
if interface_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list))
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__in=interface_ids
family=family, nat_inside__interface__device=self.instance
)
if nat_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list))
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
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another.
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
# Limit platform by manufacturer
self.fields['platform'].queryset = Platform.objects.filter(
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
)
else:
# An object that doesn't exist yet can't have any IPs assigned to it
@@ -833,10 +795,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
pk = self.instance.pk if self.instance.pk else None
try:
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack']) \
position_choices = Rack.objects.get(pk=self.data['rack'])\
.get_rack_units(face=self.data.get('face'), exclude=pk)
elif self.initial.get('rack') and str(self.initial.get('face')):
position_choices = Rack.objects.get(pk=self.initial['rack']) \
position_choices = Rack.objects.get(pk=self.initial['rack'])\
.get_rack_units(face=self.initial.get('face'), exclude=pk)
else:
position_choices = []
@@ -896,8 +858,8 @@ class BaseDeviceCSVForm(forms.ModelForm):
}
)
status = CSVChoiceField(
choices=DEVICE_STATUS_CHOICES,
help_text='Operational status'
choices=STATUS_CHOICES,
help_text='Operational status of device'
)
class Meta:
@@ -1033,7 +995,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='')
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
class Meta:
@@ -1044,7 +1006,7 @@ def device_status_choices():
status_counts = {}
for status in Device.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 DEVICE_STATUS_CHOICES]
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -1086,6 +1048,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
)
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
mac_address = forms.CharField(required=False, label='MAC address')
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
])
)
#
@@ -1362,10 +1333,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
}
class ConsoleServerPortBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
@@ -1627,10 +1594,6 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
}
class PowerOutletBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
class PowerOutletBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
@@ -1639,58 +1602,11 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
# Interfaces
#
class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN site',
widget=forms.Select(
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
)
)
vlan_group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='VLAN group',
widget=APISelect(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = [
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
]
fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1698,123 +1614,30 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or VC master)
# Limit LAG choices to interfaces belonging to this device
if self.is_bound:
device = Device.objects.get(pk=self.data['device'])
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
device_id=self.data['device'], form_factor=IFACE_FF_LAG
)
else:
device = self.instance.device
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
device=self.instance.device, form_factor=IFACE_FF_LAG
)
# Limit the queryset for the site to only include the interface's device's site
if device and device.site:
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
'group_id': self.instance.untagged_vlan.group,
'site_id': self.instance.untagged_vlan.site,
}
elif self.initial.get('tagged_vlans'):
filter_dict = {
'group_id': self.instance.tagged_vlans.first().group,
'site_id': self.instance.tagged_vlans.first().site,
}
else:
filter_dict = {
'group_id': None,
'site_id': None,
}
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
def clean_tagged_vlans(self):
"""
Because tagged_vlans is a many-to-many relationship, validation must be done in the form
"""
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError(
"An Access interface cannot have tagged VLANs."
)
if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError(
"Interface mode Tagged All implies all VLANs are tagged. "
"Do not select any tagged VLANs."
)
return self.cleaned_data['tagged_vlans']
class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
class InterfaceCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
mgmt_only = forms.BooleanField(
required=False,
label='OOB Management',
help_text='This interface is used only for out-of-band management'
)
description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN Site',
widget=forms.Select(
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
)
)
vlan_group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='VLAN group',
widget=APISelect(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
def __init__(self, *args, **kwargs):
@@ -1824,49 +1647,16 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or its VC master)
# Limit LAG choices to interfaces belonging to this device
if self.parent is not None:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
device=self.parent, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
# Limit the queryset for the site to only include the interface's device's site
if self.parent is not None and self.parent.site:
self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group') or None,
'site_id': self.data.get('site') or None,
}
elif self.initial.get('untagged_vlan'):
filter_dict = {
'group_id': self.untagged_vlan.group,
'site_id': self.untagged_vlan.site,
}
elif self.initial.get('tagged_vlans'):
filter_dict = {
'group_id': self.tagged_vlans.first().group,
'site_id': self.tagged_vlans.first().site,
}
else:
filter_dict = {
'group_id': None,
'site_id': None,
}
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
@@ -1875,59 +1665,14 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='VLAN Site',
widget=forms.Select(
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
)
)
vlan_group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='VLAN group',
widget=APISelect(
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
untagged_vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
)
)
class Meta:
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
nullable_fields = ['lag', 'mtu', 'description']
def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
# Limit LAG choices to interfaces which belong to the parent device.
device = None
if self.initial.get('device'):
try:
@@ -1937,31 +1682,11 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
if device is not None:
interface_ordering = device.device_type.interface_ordering
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
device=device, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].choices = []
# Limit the queryset for the site to only include the interface's device's site
if device and device.site:
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
self.fields['site'].initial = None
else:
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
filter_dict = {
'group_id': None,
'site_id': None,
}
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
class InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
class InterfaceBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
@@ -2132,6 +1857,11 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
return interface
class InterfaceConnectionDeletionForm(ConfirmationForm):
# Used for HTTP redirect upon successful deletion
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
#
# Device bays
#
@@ -2170,10 +1900,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
class DeviceBayBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput)
#
# Connections
#
@@ -2246,82 +1972,3 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
to_field_name='slug',
null_label='-- None --'
)
#
# Virtual chassis
#
class DeviceSelectionForm(forms.Form):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = VirtualChassis
fields = ['master', 'domain']
class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name'
)
)
def clean_device(self):
device = self.cleaned_data['device']
if device.virtual_chassis is not None:
raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
class DeviceVCMembershipForm(forms.ModelForm):
class Meta:
model = Device
fields = ['vc_position', 'vc_priority']
labels = {
'vc_position': 'Position',
'vc_priority': 'Priority',
}
def __init__(self, *args, **kwargs):
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
# Require VC position when assigning a member
self.fields['vc_position'].required = True
def clean_vc_position(self):
vc_position = self.cleaned_data['vc_position']
if Device.objects.filter(virtual_chassis=self.instance.virtual_chassis, vc_position=vc_position).exists():
raise forms.ValidationError("A virtual chassis member already exists in this position.")
return vc_position

View File

@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-11-10 20:10
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0020_ipaddress_add_role_carp'),
('dcim', '0049_rackreservation_change_user'),
]
operations = [
migrations.AddField(
model_name='interface',
name='mode',
field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
),
migrations.AddField(
model_name='interface',
name='tagged_vlans',
field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
),
migrations.AddField(
model_name='interface',
name='untagged_vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
),
]

View File

@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-11-15 18:56
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0003_unicode_literals'),
('dcim', '0050_interface_vlan_tagging'),
]
operations = [
migrations.AddField(
model_name='rackreservation',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
),
]

View File

@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-11-27 17:27
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0051_rackreservation_tenant'),
]
operations = [
migrations.CreateModel(
name='VirtualChassis',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(blank=True, max_length=30)),
('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
],
),
migrations.AddField(
model_name='device',
name='virtual_chassis',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
),
migrations.AddField(
model_name='device',
name='vc_position',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
),
migrations.AddField(
model_name='device',
name='vc_priority',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
),
migrations.AlterUniqueTogether(
name='device',
unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
),
]

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-12-19 20:56
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0052_virtual_chassis'),
]
operations = [
migrations.AddField(
model_name='platform',
name='manufacturer',
field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
),
migrations.AlterField(
model_name='platform',
name='napalm_driver',
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
),
]

View File

@@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-01-25 18:21
from __future__ import unicode_literals
from django.db import migrations, models
import timezone_field.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0053_platform_manufacturer'),
]
operations = [
migrations.AddField(
model_name='site',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='site',
name='status',
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
),
migrations.AddField(
model_name='site',
name='time_zone',
field=timezone_field.fields.TimeZoneField(blank=True),
),
]

View File

@@ -14,7 +14,6 @@ from django.db.models import Count, Q, ObjectDoesNotExist
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey
from timezone_field import TimeZoneField
from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
@@ -80,13 +79,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
facility = models.CharField(max_length=50, blank=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN')
time_zone = TimeZoneField(blank=True)
description = models.CharField(max_length=100, blank=True)
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
contact_name = models.CharField(max_length=50, blank=True)
@@ -99,8 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
objects = SiteManager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
'contact_phone', 'contact_email', 'comments',
]
class Meta:
@@ -116,13 +112,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return (
self.name,
self.slug,
self.get_status_display(),
self.region.name if self.region else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
self.time_zone,
self.description,
self.physical_address,
self.shipping_address,
self.contact_name,
@@ -131,9 +124,6 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
self.comments,
)
def get_status_class(self):
return STATUS_CLASSES[self.status]
@property
def count_prefixes(self):
return self.prefixes.count()
@@ -441,7 +431,6 @@ class RackReservation(models.Model):
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
user = models.ForeignKey(User, on_delete=models.PROTECT)
description = models.CharField(max_length=100)
@@ -796,33 +785,18 @@ class DeviceRole(models.Model):
@python_2_unicode_compatible
class Platform(models.Model):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver.
specifying an remote procedure call (RPC) client.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
manufacturer = models.ForeignKey(
to='Manufacturer',
related_name='platforms',
blank=True,
null=True,
help_text="Optionally limit this platform to devices of a certain manufacturer"
)
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
help_text="The name of the NAPALM driver to use when interacting with devices"
)
rpc_client = models.CharField(
max_length=30,
choices=RPC_CLIENT_CHOICES,
blank=True,
verbose_name="Legacy RPC client"
)
napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
help_text="The name of the NAPALM driver to use when interacting with devices.")
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
verbose_name='Legacy RPC client')
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
csv_headers = ['name', 'slug', 'napalm_driver']
class Meta:
ordering = ['name']
@@ -837,7 +811,6 @@ class Platform(models.Model):
return (
self.name,
self.slug,
self.manufacturer.name if self.manufacturer else None,
self.napalm_driver,
)
@@ -878,7 +851,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
help_text='The lowest-numbered unit occupied by the device'
)
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv4'
@@ -894,23 +867,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
blank=True,
null=True
)
virtual_chassis = models.ForeignKey(
to='VirtualChassis',
on_delete=models.SET_NULL,
related_name='members',
blank=True,
null=True
)
vc_position = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MaxValueValidator(255)]
)
vc_priority = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MaxValueValidator(255)]
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
@@ -924,10 +880,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
unique_together = [
['rack', 'position', 'face'],
['virtual_chassis', 'vc_position'],
]
unique_together = ['rack', 'position', 'face']
permissions = (
('napalm_read', 'Read-only access to devices via NAPALM'),
('napalm_write', 'Read/write access to devices via NAPALM'),
@@ -996,36 +949,29 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
except DeviceType.DoesNotExist:
pass
# Validate primary IP addresses
vc_interfaces = self.vc_interfaces.all()
if self.primary_ip4:
if self.primary_ip4.interface in vc_interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
pass
else:
raise ValidationError({
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
self.primary_ip4),
})
if self.primary_ip6:
if self.primary_ip6.interface in vc_interfaces:
pass
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
pass
else:
raise ValidationError({
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
self.primary_ip6),
})
# Validate primary IPv4 address
if self.primary_ip4 and (
self.primary_ip4.interface is None or
self.primary_ip4.interface.device != self
) and (
self.primary_ip4.nat_inside.interface is None or
self.primary_ip4.nat_inside.interface.device != self
):
raise ValidationError({
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
})
# Validate manufacturer/platform
if self.device_type and self.platform:
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
raise ValidationError({
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
})
# Validate primary IPv6 address
if self.primary_ip6 and (
self.primary_ip6.interface is None or
self.primary_ip6.interface.device != self
) and (
self.primary_ip6.nat_inside.interface is None or
self.primary_ip6.nat_inside.interface.device != self
):
raise ValidationError({
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
@@ -1033,12 +979,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
})
# Validate virtual chassis assignment
if self.virtual_chassis and self.vc_position is None:
raise ValidationError({
'vc_position': "A device assigned to a virtual chassis must have its position defined."
})
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
@@ -1098,8 +1038,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def display_name(self):
if self.name:
return self.name
elif self.virtual_chassis and self.virtual_chassis.master.name:
return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
elif hasattr(self, 'device_type'):
return "{}".format(self.device_type)
return ""
@@ -1124,23 +1062,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
else:
return None
def get_vc_master(self):
"""
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
"""
return self.virtual_chassis.master if self.virtual_chassis else None
@property
def vc_interfaces(self):
"""
Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
Device belonging to the same VirtualChassis.
"""
filter = Q(device=self)
if self.virtual_chassis and self.virtual_chassis.master == self:
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
return Interface.objects.filter(filter)
def get_children(self):
"""
Return the set of child Devices installed in DeviceBays within this Device.
@@ -1148,7 +1069,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
return Device.objects.filter(parent_bay__device=self.pk)
def get_status_class(self):
return STATUS_CLASSES[self.status]
return DEVICE_STATUS_CLASSES[self.status]
def get_rpc_client(self):
"""
@@ -1183,9 +1104,6 @@ class ConsolePort(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.cs_port.device.identifier if self.cs_port else None,
@@ -1226,9 +1144,6 @@ class ConsoleServerPort(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self):
# Check that the parent device's DeviceType is a console server
@@ -1265,9 +1180,6 @@ class PowerPort(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.power_outlet.device.identifier if self.power_outlet else None,
@@ -1308,9 +1220,6 @@ class PowerOutlet(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self):
# Check that the parent device's DeviceType is a PDU
@@ -1366,24 +1275,6 @@ class Interface(models.Model):
help_text="This interface is used only for out-of-band management"
)
description = models.CharField(max_length=100, blank=True)
mode = models.PositiveSmallIntegerField(
choices=IFACE_MODE_CHOICES,
blank=True,
null=True
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
null=True,
blank=True,
verbose_name='Untagged VLAN',
related_name='interfaces_as_untagged'
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
blank=True,
verbose_name='Tagged VLANs',
related_name='interfaces_as_tagged'
)
objects = InterfaceQuerySet.as_manager()
@@ -1394,9 +1285,6 @@ class Interface(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.parent.get_absolute_url()
def clean(self):
# Check that the parent device's DeviceType is a network device
@@ -1426,8 +1314,8 @@ class Interface(models.Model):
"Disconnect the interface or choose a suitable form factor."
})
# An interface's LAG must belong to the same device (or VC master)
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
# An interface's LAG must belong to the same device
if self.lag and self.lag.device != self.device:
raise ValidationError({
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
self.lag.name, self.lag.device.name
@@ -1448,13 +1336,6 @@ class Interface(models.Model):
)
})
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
"device/VM, or it must be global".format(self.untagged_vlan)
})
@property
def parent(self):
return self.device or self.virtual_machine
@@ -1558,9 +1439,6 @@ class DeviceBay(models.Model):
def __str__(self):
return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self):
# Validate that the parent Device can have DeviceBays
@@ -1610,9 +1488,6 @@ class InventoryItem(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def to_csv(self):
return (
self.device.name or '{' + self.device.pk + '}',
@@ -1624,38 +1499,3 @@ class InventoryItem(models.Model):
self.discovered,
self.description,
)
#
# Virtual chassis
#
@python_2_unicode_compatible
class VirtualChassis(models.Model):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
"""
master = models.OneToOneField(
to='Device',
on_delete=models.PROTECT,
related_name='vc_master_for'
)
domain = models.CharField(
max_length=30,
blank=True
)
def __str__(self):
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
def get_absolute_url(self):
return self.master.get_absolute_url()
def clean(self):
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
# VirtualChassis.)
if self.pk and self.master not in self.members.all():
raise ValidationError({
'master': "The selected master is not assigned to this virtual chassis."
})

View File

@@ -1,23 +0,0 @@
from __future__ import unicode_literals
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from .models import Device, VirtualChassis
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
When a VirtualChassis is created, automatically assign its master device to the VC.
"""
if created:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
@receiver(pre_delete, sender=VirtualChassis)
def clear_virtualchassis_members(instance, **kwargs):
"""
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
"""
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)

View File

@@ -9,7 +9,6 @@ from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
VirtualChassis,
)
REGION_LINK = """
@@ -114,7 +113,7 @@ DEVICE_ROLE = """
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_LABEL = """
DEVICE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@@ -133,12 +132,6 @@ UTILIZATION_GRAPH = """
{% utilization_graph value %}
"""
VIRTUALCHASSIS_ACTIONS = """
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
#
# Regions
@@ -167,13 +160,27 @@ class RegionTable(BaseTable):
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.TemplateColumn(template_code=COL_TENANT)
class Meta(BaseTable.Meta):
model = Site
fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
class SiteDetailTable(SiteTable):
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
vm_count = tables.Column(accessor=Accessor('count_vms'), orderable=False, verbose_name='VMs')
class Meta(SiteTable.Meta):
fields = (
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count', 'vm_count',
)
#
@@ -263,7 +270,6 @@ class RackImportTable(BaseTable):
class RackReservationTable(BaseTable):
pk = ToggleColumn()
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
@@ -272,7 +278,7 @@ class RackReservationTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
#
@@ -283,14 +289,13 @@ class ManufacturerTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
devicetype_count = tables.Column(verbose_name='Device Types')
platform_count = tables.Column(verbose_name='Platforms')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='')
class Meta(BaseTable.Meta):
model = Manufacturer
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
#
@@ -432,7 +437,7 @@ class PlatformTable(BaseTable):
class Meta(BaseTable.Meta):
model = Platform
fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
#
@@ -442,7 +447,7 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=DEVICE_LINK)
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
@@ -469,7 +474,7 @@ class DeviceDetailTable(DeviceTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
@@ -582,22 +587,3 @@ class InventoryItemTable(BaseTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
#
# Virtual chassis
#
class VirtualChassisTable(BaseTable):
pk = ToggleColumn()
master = tables.LinkColumn()
member_count = tables.Column(verbose_name='Members')
actions = tables.TemplateColumn(
template_code=VIRTUALCHASSIS_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'master', 'domain', 'member_count', 'actions')

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
'face': RACK_FACE_FRONT,
'position': 41,
'platform': get_id(Platform, 'juniper-junos'),
'status': DEVICE_STATUS_ACTIVE,
'status': STATUS_ACTIVE,
})
self.assertTrue(test.is_valid(), test.fields['position'].choices)
self.assertTrue(test.save())
@@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
'face': RACK_FACE_FRONT,
'position': 1,
'platform': get_id(Platform, 'juniper-junos'),
'status': DEVICE_STATUS_ACTIVE,
'status': STATUS_ACTIVE,
})
self.assertFalse(test.is_valid())
@@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
'face': None,
'position': None,
'platform': None,
'status': DEVICE_STATUS_ACTIVE,
'status': STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
@@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
'face': RACK_FACE_REAR,
'position': None,
'platform': None,
'status': DEVICE_STATUS_ACTIVE,
'status': STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())

View File

@@ -140,8 +140,8 @@ urlpatterns = [
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
@@ -150,18 +150,17 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
# Power ports
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
@@ -170,11 +169,10 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@@ -182,11 +180,10 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
# Device bays
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@@ -194,9 +191,8 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
# Inventory items
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
@@ -215,12 +211,4 @@ urlpatterns = [
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Virtual chassis
url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
form = CustomFieldForm
def models(self, obj):

View File

@@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
from extras import filters
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
from . import serializers
@@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet):
# Graphs
#
class GraphViewSet(ModelViewSet):
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
write_serializer_class = serializers.WritableGraphSerializer
@@ -75,7 +75,7 @@ class GraphViewSet(ModelViewSet):
# Export templates
#
class ExportTemplateViewSet(ModelViewSet):
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer
filter_class = filters.ExportTemplateFilter
@@ -85,7 +85,7 @@ class ExportTemplateViewSet(ModelViewSet):
# Topology maps
#
class TopologyMapViewSet(ModelViewSet):
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
queryset = TopologyMap.objects.select_related('site')
serializer_class = serializers.TopologyMapSerializer
write_serializer_class = serializers.WritableTopologyMapSerializer
@@ -115,7 +115,7 @@ class TopologyMapViewSet(ModelViewSet):
# Image attachments
#
class ImageAttachmentViewSet(ModelViewSet):
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer

View File

@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
(CF_TYPE_SELECT, 'Selection'),
)
# Custom field filter logic choices
CF_FILTER_DISABLED = 0
CF_FILTER_LOOSE = 1
CF_FILTER_EXACT = 2
CF_FILTER_CHOICES = (
(CF_FILTER_DISABLED, 'Disabled'),
(CF_FILTER_LOOSE, 'Loose'),
(CF_FILTER_EXACT, 'Exact'),
)
# Graph types
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization
]
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
TOPOLOGYMAP_TYPE_POWER = 3
TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# User action types
ACTION_CREATE = 1
ACTION_IMPORT = 2

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from dcim.models import Site
from .constants import CF_TYPE_SELECT
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
@@ -14,8 +14,9 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
"""
def __init__(self, cf_type, *args, **kwargs):
self.cf_type = cf_type
def __init__(self, custom_field, *args, **kwargs):
self.cf_type = custom_field.type
self.filter_logic = custom_field.filter_logic
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
@@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
except ValueError:
return queryset.none()
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value,
)
# Apply the assigned filter logic (exact or loose)
queryset = queryset.filter(custom_field_values__field__name=self.name)
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
return queryset.filter(custom_field_values__serialized_value=value)
else:
return queryset.filter(custom_field_values__serialized_value__icontains=value)
class CustomFieldFilterSet(django_filters.FilterSet):
@@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
class GraphFilter(django_filters.FilterSet):

View File

@@ -6,7 +6,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment
@@ -15,10 +15,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
Retrieve all CustomFields applicable to the given ContentType
"""
field_dict = OrderedDict()
kwargs = {'obj_type': content_type}
custom_fields = CustomField.objects.filter(obj_type=content_type)
if filterable_only:
kwargs['is_filterable'] = True
custom_fields = CustomField.objects.filter(**kwargs)
custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name))
@@ -35,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(1, 'True'),
(0, 'False'),
)
if initial.lower() in ['true', 'yes', '1']:
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial.lower() in ['false', 'no', '0']:
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None

View File

@@ -8,7 +8,7 @@ from django.db import transaction
from ncclient.transport.errors import AuthenticationError
from paramiko import AuthenticationException
from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
class Command(BaseCommand):
@@ -41,7 +41,7 @@ class Command(BaseCommand):
self.password = getpass("Password: ")
# Attempt to inventory only active devices
device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
device_list = Device.objects.filter(status=STATUS_ACTIVE)
# --site: Include only devices belonging to specified site(s)
if options['site']:

View File

@@ -4,14 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models
from extras.models import TopologyMap
def commas_to_semicolons(apps, schema_editor):
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
tm.device_patterns = tm.device_patterns.replace(',', ';')
tm.save()
class Migration(migrations.Migration):
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
name='device_patterns',
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
),
migrations.RunPython(commas_to_semicolons),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-15 16:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0008_reports'),
]
operations = [
migrations.AddField(
model_name='topologymap',
name='type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
),
]

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 19:48
from __future__ import unicode_literals
from django.db import migrations, models
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
def is_filterable_to_filter_logic(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
# Select fields match on primary key only
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
def filter_logic_to_is_filterable(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
class Migration(migrations.Migration):
dependencies = [
('extras', '0009_topologymap_type'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='filter_logic',
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
),
migrations.AlterField(
model_name='customfield',
name='required',
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
),
migrations.AlterField(
model_name='customfield',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
),
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
migrations.RemoveField(
model_name='customfield',
name='is_filterable',
),
]

View File

@@ -16,6 +16,7 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color
from .constants import *
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
help_text="The object(s) to which this field applies.")
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
name = models.CharField(max_length=50, unique=True)
label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
"provided, the field's name will be used)")
description = models.CharField(max_length=100, blank=True)
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
"new objects or editing an existing object.")
is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
"\"false\" for booleans. N/A for selection "
"fields.")
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
"form")
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
help_text='The object(s) to which this field applies.'
)
type = models.PositiveSmallIntegerField(
choices=CUSTOMFIELD_TYPE_CHOICES,
default=CF_TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
)
description = models.CharField(
max_length=100,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects or editing an existing object.'
)
filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE,
help_text="Loose matches any instance of a given string; exact matches the entire field."
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
class Meta:
ordering = ['weight', 'name']
@@ -253,7 +280,17 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
related_name='topology_maps',
blank=True,
null=True,
on_delete=models.CASCADE
)
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -275,22 +312,26 @@ class TopologyMap(models.Model):
def render(self, img_format='png'):
from circuits.models import CircuitTermination
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
from dcim.models import Device
# Construct the graph
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
G = graphviz.Graph
else:
G = graphviz.Digraph
self.graph = G()
self.graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i))
subgraph = G(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same'
subgraph.graph_attr['directed'] = 'true'
# Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i:
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph
devices = []
@@ -308,31 +349,64 @@ class TopologyMap(models.Model):
for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
graph.subgraph(subgraph)
self.graph.subgraph(subgraph)
# Compile list of all devices
device_superset = Q()
for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
devices = Device.objects.filter(*(device_superset,))
# Draw edges depending on graph type
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
self.add_network_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
self.add_console_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_POWER:
self.add_power_connections(devices)
return self.graph.pipe(format=img_format)
def add_network_connections(self, devices):
from circuits.models import CircuitTermination
from dcim.models import InterfaceConnection
# Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices
)
for c in connections:
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination()
if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices):
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format)
def add_console_connections(self, devices):
from dcim.models import ConsolePort
# Add all console connections to the graph
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
for cp in console_ports:
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
def add_power_connections(self, devices):
from dcim.models import PowerPort
# Add all power connections to the graph
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
for pp in power_ports:
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
#

View File

@@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
}
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.objects.count(), 4)
@@ -63,35 +63,6 @@ class GraphTest(HttpStatusMixin, APITestCase):
self.assertEqual(graph4.name, data['name'])
self.assertEqual(graph4.source, data['source'])
def test_create_graph_bulk(self):
data = [
{
'type': GRAPH_TYPE_SITE,
'name': 'Test Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
},
{
'type': GRAPH_TYPE_SITE,
'name': 'Test Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': GRAPH_TYPE_SITE,
'name': 'Test Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.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_graph(self):
data = {
@@ -101,7 +72,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
}
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Graph.objects.count(), 3)
@@ -164,7 +135,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
}
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.objects.count(), 4)
@@ -173,35 +144,6 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(exporttemplate4.name, data['name'])
self.assertEqual(exporttemplate4.template_code, data['template_code'])
def test_create_exporttemplate_bulk(self):
data = [
{
'content_type': self.content_type.pk,
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': self.content_type.pk,
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': self.content_type.pk,
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
]
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.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_exporttemplate(self):
data = {
@@ -211,7 +153,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
}
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ExportTemplate.objects.count(), 3)

View File

@@ -3,11 +3,9 @@ from __future__ import unicode_literals
from collections import OrderedDict
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from ipam.constants import (
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
@@ -27,10 +25,7 @@ class VRFSerializer(CustomFieldModelSerializer):
class Meta:
model = VRF
fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
'last_updated',
]
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
class NestedVRFSerializer(serializers.ModelSerializer):
@@ -45,9 +40,7 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
class Meta:
model = VRF
fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
]
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
#
@@ -97,9 +90,7 @@ class AggregateSerializer(CustomFieldModelSerializer):
class Meta:
model = Aggregate
fields = [
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
]
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
class NestedAggregateSerializer(serializers.ModelSerializer):
@@ -114,7 +105,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
class Meta:
model = Aggregate
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
#
@@ -174,7 +165,7 @@ class VLANSerializer(CustomFieldModelSerializer):
model = VLAN
fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
@@ -190,10 +181,7 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
class Meta:
model = VLAN
fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
'last_updated',
]
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
validators = []
def validate(self, data):
@@ -227,7 +215,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
model = Prefix
fields = [
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
@@ -245,47 +233,23 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
model = Prefix
fields = [
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
class AvailablePrefixSerializer(serializers.Serializer):
def to_representation(self, instance):
if self.context.get('vrf'):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
else:
vrf = None
return OrderedDict([
('family', instance.version),
('prefix', str(instance)),
('vrf', vrf),
])
#
# IP addresses
#
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
device = NestedDeviceSerializer()
class IPAddressInterfaceSerializer(InterfaceSerializer):
virtual_machine = NestedVirtualMachineSerializer()
class Meta(InterfaceSerializer.Meta):
model = Interface
fields = [
'id', 'url', 'device', 'virtual_machine', 'name',
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
]
def get_url(self, obj):
"""
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
if the parent is a VirtualMachine.
"""
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer()
@@ -298,7 +262,7 @@ class IPAddressSerializer(CustomFieldModelSerializer):
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'nat_outside', 'custom_fields', 'created', 'last_updated',
'nat_outside', 'custom_fields',
]
@@ -320,7 +284,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
model = IPAddress
fields = [
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'custom_fields', 'created', 'last_updated',
'custom_fields',
]
@@ -350,10 +314,7 @@ class ServiceSerializer(serializers.ModelSerializer):
class Meta:
model = Service
fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
'last_updated',
]
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
@@ -361,7 +322,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
class Meta:
model = Service
fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
'last_updated',
]
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']

View File

@@ -6,11 +6,12 @@ from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from extras.api.views import CustomFieldModelViewSet
from ipam import filters
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from utilities.api import FieldChoicesViewSet, ModelViewSet
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
@@ -32,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
# VRFs
#
class VRFViewSet(CustomFieldModelViewSet):
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = VRF.objects.select_related('tenant')
serializer_class = serializers.VRFSerializer
write_serializer_class = serializers.WritableVRFSerializer
@@ -53,7 +54,7 @@ class RIRViewSet(ModelViewSet):
# Aggregates
#
class AggregateViewSet(CustomFieldModelViewSet):
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Aggregate.objects.select_related('rir')
serializer_class = serializers.AggregateSerializer
write_serializer_class = serializers.WritableAggregateSerializer
@@ -74,72 +75,12 @@ class RoleViewSet(ModelViewSet):
# Prefixes
#
class PrefixViewSet(CustomFieldModelViewSet):
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
serializer_class = serializers.PrefixSerializer
write_serializer_class = serializers.WritablePrefixSerializer
filter_class = filters.PrefixFilter
@detail_route(url_path='available-prefixes', methods=['get', 'post'])
def available_prefixes(self, request, pk=None):
"""
A convenience method for returning available child prefixes within a parent.
"""
prefix = get_object_or_404(Prefix, pk=pk)
available_prefixes = prefix.get_available_prefixes()
if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_prefix'):
raise PermissionDenied()
# Normalize to a list of objects
requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
# Allocate prefixes to the requested objects based on availability within the parent
for requested_prefix in requested_prefixes:
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_400_BAD_REQUEST
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
if isinstance(request.data, list):
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
else:
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
# Create the new Prefix(es)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
'request': request,
'vrf': prefix.vrf,
})
return Response(serializer.data)
@detail_route(url_path='available-ips', methods=['get', 'post'])
def available_ips(self, request, pk=None):
"""
@@ -156,39 +97,28 @@ class PrefixViewSet(CustomFieldModelViewSet):
if not request.user.has_perm('ipam.add_ipaddress'):
raise PermissionDenied()
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = list(prefix.get_available_ips())
if len(available_ips) < len(requested_ips):
# Find the first available IP address in the prefix
try:
ipaddress = list(prefix.get_available_ips())[0]
except IndexError:
return Response(
{
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
"detail": "There are no available IPs within this prefix ({})".format(prefix)
},
status=status.HTTP_400_BAD_REQUEST
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
for requested_ip in requested_ips:
requested_ip['address'] = available_ips.pop(0)
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
if isinstance(request.data, list):
serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
else:
serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
# Create the new IP address(es)
# Create the new IP address
data = request.data.copy()
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
serializer = serializers.WritableIPAddressSerializer(data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Determine the maximum number of IPs to return
# Determine the maximum amount of IPs to return
else:
try:
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
@@ -216,11 +146,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
# IP addresses
#
class IPAddressViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'nat_outside'
'interface__device', 'interface__virtual_machine'
)
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
@@ -231,7 +161,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
# VLAN groups
#
class VLANGroupViewSet(ModelViewSet):
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
queryset = VLANGroup.objects.select_related('site')
serializer_class = serializers.VLANGroupSerializer
write_serializer_class = serializers.WritableVLANGroupSerializer
@@ -242,7 +172,7 @@ class VLANGroupViewSet(ModelViewSet):
# VLANs
#
class VLANViewSet(CustomFieldModelViewSet):
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
serializer_class = serializers.VLANSerializer
write_serializer_class = serializers.WritableVLANSerializer
@@ -253,7 +183,7 @@ class VLANViewSet(CustomFieldModelViewSet):
# Services
#
class ServiceViewSet(ModelViewSet):
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Service.objects.select_related('device')
serializer_class = serializers.ServiceSerializer
write_serializer_class = serializers.WritableServiceSerializer

View File

@@ -99,6 +99,11 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
# TODO: Deprecate in v2.3.0
parent = django_filters.CharFilter(
method='search_within_include',
label='Parent prefix (deprecated)',
)
within = django_filters.CharFilter(
method='search_within',
label='Within prefix',
@@ -257,16 +262,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
name='name',
label='Device',
)
device_id = django_filters.NumberFilter(
method='filter_device',
name='pk',
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='interface__device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
name='interface__virtual_machine',
queryset=VirtualMachine.objects.all(),
@@ -318,14 +324,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
return queryset
return queryset.filter(address__net_mask_length=value)
def filter_device(self, queryset, name, value):
try:
device = Device.objects.select_related('device_type').get(**{name: value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(

View File

@@ -934,9 +934,8 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface_id__in=vc_interface_ids
interface__device=self.instance.device
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-07 18:37
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0020_ipaddress_add_role_carp'),
]
operations = [
migrations.AlterModelOptions(
name='vrf',
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
),
]

View File

@@ -37,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta:
ordering = ['name']
ordering = ['name', 'rd']
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'

View File

@@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vrf-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VRF.objects.count(), 4)
@@ -55,32 +55,6 @@ class VRFTest(HttpStatusMixin, APITestCase):
self.assertEqual(vrf4.name, data['name'])
self.assertEqual(vrf4.rd, data['rd'])
def test_create_vrf_bulk(self):
data = [
{
'name': 'Test VRF 4',
'rd': '65000:4',
},
{
'name': 'Test VRF 5',
'rd': '65000:5',
},
{
'name': 'Test VRF 6',
'rd': '65000:6',
},
]
url = reverse('ipam-api:vrf-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VRF.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_vrf(self):
data = {
@@ -89,7 +63,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VRF.objects.count(), 3)
@@ -140,7 +114,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:rir-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RIR.objects.count(), 4)
@@ -148,32 +122,6 @@ class RIRTest(HttpStatusMixin, APITestCase):
self.assertEqual(rir4.name, data['name'])
self.assertEqual(rir4.slug, data['slug'])
def test_create_rir_bulk(self):
data = [
{
'name': 'Test RIR 4',
'slug': 'test-rir-4',
},
{
'name': 'Test RIR 5',
'slug': 'test-rir-5',
},
{
'name': 'Test RIR 6',
'slug': 'test-rir-6',
},
]
url = reverse('ipam-api:rir-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RIR.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_rir(self):
data = {
@@ -182,7 +130,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(RIR.objects.count(), 3)
@@ -235,7 +183,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:aggregate-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Aggregate.objects.count(), 4)
@@ -243,32 +191,6 @@ class AggregateTest(HttpStatusMixin, APITestCase):
self.assertEqual(str(aggregate4.prefix), data['prefix'])
self.assertEqual(aggregate4.rir_id, data['rir'])
def test_create_aggregate_bulk(self):
data = [
{
'prefix': '100.0.0.0/8',
'rir': self.rir1.pk,
},
{
'prefix': '101.0.0.0/8',
'rir': self.rir1.pk,
},
{
'prefix': '102.0.0.0/8',
'rir': self.rir1.pk,
},
]
url = reverse('ipam-api:aggregate-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Aggregate.objects.count(), 6)
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
def test_update_aggregate(self):
data = {
@@ -277,7 +199,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Aggregate.objects.count(), 3)
@@ -328,7 +250,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:role-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Role.objects.count(), 4)
@@ -336,32 +258,6 @@ class RoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(role4.name, data['name'])
self.assertEqual(role4.slug, data['slug'])
def test_create_role_bulk(self):
data = [
{
'name': 'Test Role 4',
'slug': 'test-role-4',
},
{
'name': 'Test Role 5',
'slug': 'test-role-5',
},
{
'name': 'Test Role 6',
'slug': 'test-role-6',
},
]
url = reverse('ipam-api:role-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Role.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_role(self):
data = {
@@ -370,7 +266,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Role.objects.count(), 3)
@@ -428,7 +324,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:prefix-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Prefix.objects.count(), 4)
@@ -439,29 +335,6 @@ class PrefixTest(HttpStatusMixin, APITestCase):
self.assertEqual(prefix4.vlan_id, data['vlan'])
self.assertEqual(prefix4.role_id, data['role'])
def test_create_prefix_bulk(self):
data = [
{
'prefix': '10.0.1.0/24',
},
{
'prefix': '10.0.2.0/24',
},
{
'prefix': '10.0.3.0/24',
},
]
url = reverse('ipam-api:prefix-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Prefix.objects.count(), 6)
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
def test_update_prefix(self):
data = {
@@ -473,7 +346,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Prefix.objects.count(), 3)
@@ -492,73 +365,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Prefix.objects.count(), 2)
def test_list_available_prefixes(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'))
Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'))
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Retrieve all available IPs
response = self.client.get(url, **self.header)
available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
for i, p in enumerate(response.data):
self.assertEqual(p['prefix'], available_prefixes[i])
def test_create_single_available_prefix(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests
prefixes_to_be_created = [
'192.0.2.0/30',
'192.0.2.4/30',
'192.0.2.8/30',
'192.0.2.12/30',
]
for i in range(4):
data = {
'prefix_length': 30,
'description': 'Test Prefix {}'.format(i + 1)
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix
response = self.client.post(url, {'prefix_length': 30}, **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
def test_create_multiple_available_prefixes(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Try to create five /30s (only four are available)
data = [
{'prefix_length': 30, 'description': 'Test Prefix 1'},
{'prefix_length': 30, 'description': 'Test Prefix 2'},
{'prefix_length': 30, 'description': 'Test Prefix 3'},
{'prefix_length': 30, 'description': 'Test Prefix 4'},
{'prefix_length': 30, 'description': 'Test Prefix 5'},
]
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
# Verify that no prefixes were created (the entire /28 is still available)
response = self.client.get(url, **self.header)
self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28')
# Create four /30s in a single request
response = self.client.post(url, data[:4], format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 4)
def test_list_available_ips(self):
def test_available_ips(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
@@ -573,17 +380,12 @@ class PrefixTest(HttpStatusMixin, APITestCase):
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
def test_create_single_available_ip(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests
for i in range(1, 5):
# Create all six available IPs
for i in range(6):
data = {
'description': 'Test IP {}'.format(i)
}
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['description'], data['description'])
@@ -592,27 +394,6 @@ class PrefixTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Try to create nine IPs (only eight are available)
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
# Verify that no IPs were created (eight are still available)
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 8)
# Create all eight available IPs in a single request
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)] # 8 IPs
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 8)
class IPAddressTest(HttpStatusMixin, APITestCase):
@@ -649,7 +430,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:ipaddress-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(IPAddress.objects.count(), 4)
@@ -657,29 +438,6 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
self.assertEqual(str(ipaddress4.address), data['address'])
self.assertEqual(ipaddress4.vrf_id, data['vrf'])
def test_create_ipaddress_bulk(self):
data = [
{
'address': '192.168.0.4/24',
},
{
'address': '192.168.0.5/24',
},
{
'address': '192.168.0.6/24',
},
]
url = reverse('ipam-api:ipaddress-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(IPAddress.objects.count(), 6)
self.assertEqual(response.data[0]['address'], data[0]['address'])
self.assertEqual(response.data[1]['address'], data[1]['address'])
self.assertEqual(response.data[2]['address'], data[2]['address'])
def test_update_ipaddress(self):
data = {
@@ -688,7 +446,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(IPAddress.objects.count(), 3)
@@ -739,7 +497,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vlangroup-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VLANGroup.objects.count(), 4)
@@ -747,32 +505,6 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(vlangroup4.name, data['name'])
self.assertEqual(vlangroup4.slug, data['slug'])
def test_create_vlangroup_bulk(self):
data = [
{
'name': 'Test VLAN Group 4',
'slug': 'test-vlan-group-4',
},
{
'name': 'Test VLAN Group 5',
'slug': 'test-vlan-group-5',
},
{
'name': 'Test VLAN Group 6',
'slug': 'test-vlan-group-6',
},
]
url = reverse('ipam-api:vlangroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VLANGroup.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_vlangroup(self):
data = {
@@ -781,7 +513,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VLANGroup.objects.count(), 3)
@@ -832,7 +564,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VLAN.objects.count(), 4)
@@ -840,32 +572,6 @@ class VLANTest(HttpStatusMixin, APITestCase):
self.assertEqual(vlan4.vid, data['vid'])
self.assertEqual(vlan4.name, data['name'])
def test_create_vlan_bulk(self):
data = [
{
'vid': 4,
'name': 'Test VLAN 4',
},
{
'vid': 5,
'name': 'Test VLAN 5',
},
{
'vid': 6,
'name': 'Test VLAN 6',
},
]
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VLAN.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_vlan(self):
data = {
@@ -874,7 +580,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VLAN.objects.count(), 3)
@@ -943,7 +649,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:service-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Service.objects.count(), 4)
@@ -953,38 +659,6 @@ class ServiceTest(HttpStatusMixin, APITestCase):
self.assertEqual(service4.protocol, data['protocol'])
self.assertEqual(service4.port, data['port'])
def test_create_service_bulk(self):
data = [
{
'device': self.device1.pk,
'name': 'Test Service 4',
'protocol': IP_PROTOCOL_TCP,
'port': 4,
},
{
'device': self.device1.pk,
'name': 'Test Service 5',
'protocol': IP_PROTOCOL_TCP,
'port': 5,
},
{
'device': self.device1.pk,
'name': 'Test Service 6',
'protocol': IP_PROTOCOL_TCP,
'port': 6,
},
]
url = reverse('ipam-api:service-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Service.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_service(self):
data = {
@@ -995,7 +669,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
}
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Service.objects.count(), 3)

View File

@@ -1,8 +1,6 @@
import logging
import os
import socket
import sys
import warnings
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured
@@ -14,15 +12,8 @@ except ImportError:
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
)
# Raise a deprecation warning for Python 2.x
if sys.version_info[0] < 3:
warnings.warn(
"Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest "
"opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.",
DeprecationWarning
)
VERSION = '2.3.0-beta2'
VERSION = '2.2.10'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -134,7 +125,6 @@ INSTALLED_APPS = (
'mptt',
'rest_framework',
'rest_framework_swagger',
'timezone_field',
'circuits',
'dcim',
'ipam',

View File

@@ -121,7 +121,7 @@ input[name="pk"] {
}
/* Tables */
th.pk, td.pk {
.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
padding-bottom: 6px;
padding-top: 10px;
width: 30px;

View File

@@ -0,0 +1,7 @@
I hope you love Font Awesome. If you've found it useful, please do me a favor and check out my latest project,
Fort Awesome (https://fortawesome.com). It makes it easy to put the perfect icons on your website. Choose from our awesome,
comprehensive icon sets or copy and paste your own.
Please. Check it out.
-Dave Gandy

View File

@@ -1,24 +1,14 @@
$(document).ready(function() {
// "Toggle" checkbox for object lists (PK column)
$('input:checkbox.toggle').click(function() {
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
// Show the "select all" box if present
// "Toggle all" checkbox (table header)
$('#toggle_all').click(function() {
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
if ($(this).is(':checked')) {
$('#select_all_box').removeClass('hidden');
} else {
$('#select_all').prop('checked', false);
}
});
// Uncheck the "toggle" and "select all" checkboxes if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
$('input:checkbox.toggle, #select_all').prop('checked', false);
}
});
// Enable hidden buttons when "select all" is checked
$('#select_all').click(function() {
if ($(this).is(':checked')) {
@@ -27,6 +17,21 @@ $(document).ready(function() {
$('#select_all_box').find('button').prop('disabled', 'disabled');
}
});
// Uncheck the "toggle all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
$('#select_all, #toggle_all').prop('checked', false);
}
});
// Simple "Toggle all" button (panel)
$('button.toggle').click(function() {
var selected = $(this).attr('selected');
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
return false;
});
// Slugify
function slugify(s, num_chars) {
@@ -66,65 +71,59 @@ $(document).ready(function() {
$('select[filter-for]').change(function() {
// Resolve child field by ID specified in parent
var child_names = $(this).attr('filter-for');
var parent = this;
var child_name = $(this).attr('filter-for');
var child_field = $('#id_' + child_name);
var child_selected = child_field.val();
// allow more than one child
$.each(child_names.split(" "), function(_, child_name){
// Wipe out any existing options within the child field and create a default option
child_field.empty();
if (!child_field.attr('multiple')) {
child_field.append($("<option></option>").attr("value", "").text("---------"));
}
var child_field = $('#id_' + child_name);
var child_selected = child_field.val();
if ($(this).val() || $(this).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url') + '&limit=1000';
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
// Wipe out any existing options within the child field and create a default option
child_field.empty();
if (!child_field.attr('multiple')) {
child_field.append($("<option></option>").attr("value", "").text("---------"));
// Determine the filter fields needed to make an API call
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
var rendered_url = api_url;
while (match = filter_regex.exec(api_url)) {
var filter_field = $('#id_' + match[1]);
if (filter_field.val()) {
rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') {
rendered_url = rendered_url.replace(match[0], '0');
}
}
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url') + '&limit=1000';
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
// Determine the filter fields needed to make an API call
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
var rendered_url = api_url;
while (match = filter_regex.exec(api_url)) {
var filter_field = $('#id_' + match[1]);
if (filter_field.val()) {
rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') {
rendered_url = rendered_url.replace(match[0], '0');
// If all URL variables have been replaced, make the API call
if (rendered_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + rendered_url);
$.ajax({
url: rendered_url,
dataType: 'json',
success: function(response, status) {
$.each(response.results, function(index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled");
} else if (choice.id == child_selected) {
option.attr("selected", "selected");
}
child_field.append(option);
});
}
}
// If all URL variables have been replaced, make the API call
if (rendered_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + rendered_url);
$.ajax({
url: rendered_url,
dataType: 'json',
success: function(response, status) {
$.each(response.results, function(index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled");
} else if (choice.id == child_selected) {
option.attr("selected", "selected");
}
child_field.append(option);
});
}
});
}
});
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -45,20 +45,13 @@ class WritableSecretSerializer(serializers.ModelSerializer):
class Meta:
model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
fields = ['id', 'device', 'role', 'name', 'plaintext']
validators = []
def validate(self, data):
# Encrypt plaintext data using the master key provided from the view context
if data.get('plaintext'):
s = Secret(plaintext=data['plaintext'])
s.encrypt(self.context['master_key'])
data['ciphertext'] = s.ciphertext
data['hash'] = s.hash
# Validate uniqueness of name if one has been provided.
if data.get('name'):
if data.get('name', None):
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
validator.set_context(self)
validator(data)

View File

@@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from rest_framework.viewsets import ModelViewSet, ViewSet
from secrets import filters
from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.api import FieldChoicesViewSet, ModelViewSet
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
ERR_USERKEY_MISSING = "No UserKey found for the current user."
@@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet):
# Secrets
#
class SecretViewSet(ModelViewSet):
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Secret.objects.select_related(
'device__primary_ip4', 'device__primary_ip6', 'role',
).prefetch_related(
@@ -56,13 +56,17 @@ class SecretViewSet(ModelViewSet):
master_key = None
def get_serializer_context(self):
# Make the master key available to the serializer for encrypting plaintext values
context = super(SecretViewSet, self).get_serializer_context()
context['master_key'] = self.master_key
return context
def _get_encrypted_fields(self, serializer):
"""
Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
"""
s = Secret(plaintext=serializer.validated_data['plaintext'])
s.encrypt(self.master_key)
return ({
'ciphertext': s.ciphertext,
'hash': s.hash,
})
def initial(self, request, *args, **kwargs):
@@ -124,6 +128,12 @@ class SecretViewSet(ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def perform_create(self, serializer):
serializer.save(**self._get_encrypted_fields(serializer))
def perform_update(self, serializer):
serializer.save(**self._get_encrypted_fields(serializer))
class GetSessionKeyViewSet(ViewSet):
"""

View File

@@ -81,12 +81,12 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
def test_create_secretrole(self):
data = {
'name': 'Test Secret Role 4',
'slug': 'test-secret-role-4',
'name': 'Test SecretRole 4',
'slug': 'test-secretrole-4',
}
url = reverse('secrets-api:secretrole-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(SecretRole.objects.count(), 4)
@@ -94,32 +94,6 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(secretrole4.name, data['name'])
self.assertEqual(secretrole4.slug, data['slug'])
def test_create_secretrole_bulk(self):
data = [
{
'name': 'Test Secret Role 4',
'slug': 'test-secret-role-4',
},
{
'name': 'Test Secret Role 5',
'slug': 'test-secret-role-5',
},
{
'name': 'Test Secret Role 6',
'slug': 'test-secret-role-6',
},
]
url = reverse('secrets-api:secretrole-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(SecretRole.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_secretrole(self):
data = {
@@ -128,7 +102,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
}
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(SecretRole.objects.count(), 3)
@@ -164,9 +138,9 @@ class SecretTest(HttpStatusMixin, APITestCase):
}
self.plaintext = {
'secret1': 'Secret #1 Plaintext',
'secret2': 'Secret #2 Plaintext',
'secret3': 'Secret #3 Plaintext',
'secret1': 'Secret#1Plaintext',
'secret2': 'Secret#2Plaintext',
'secret3': 'Secret#3Plaintext',
}
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
@@ -213,12 +187,11 @@ class SecretTest(HttpStatusMixin, APITestCase):
data = {
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext',
'plaintext': 'Secret#4Plaintext',
}
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['plaintext'], data['plaintext'])
@@ -228,38 +201,6 @@ class SecretTest(HttpStatusMixin, APITestCase):
self.assertEqual(secret4.role_id, data['role'])
self.assertEqual(secret4.plaintext, data['plaintext'])
def test_create_secret_bulk(self):
data = [
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext',
},
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 5',
'plaintext': 'Secret #5 Plaintext',
},
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 6',
'plaintext': 'Secret #6 Plaintext',
},
]
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Secret.objects.count(), 6)
self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
def test_update_secret(self):
data = {
@@ -269,7 +210,7 @@ class SecretTest(HttpStatusMixin, APITestCase):
}
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.put(url, data, format='json', **self.header)
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['plaintext'], data['plaintext'])

View File

@@ -62,7 +62,7 @@
</div>
</div>
</footer>
<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>

View File

@@ -46,12 +46,6 @@
<strong>Circuit</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Status</td>
<td>
<span class="label label-{{ circuit.get_status_class }}">{{ circuit.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Provider</td>
<td>

View File

@@ -8,7 +8,6 @@
{% render_field form.provider %}
{% render_field form.cid %}
{% render_field form.type %}
{% render_field form.status %}
{% render_field form.install_date %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,55 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<h1>{% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
<div class="row">
<div class="col-md-7">
<table class="table">
<thead>
<tr>
<th>Current Name</th>
<th>New Name</th>
</tr>
</thead>
<tbody>
{% for obj in selected_objects %}
<tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}>
<td>{{ obj.name }}</td>
<td>{{ obj.new_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="col-md-5">
<form action="" method="post" class="form form-horizontal">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Rename</strong></div>
<div class="panel-body">
{% render_form form %}
</div>
</div>
<div class="form-group text-right">
<div class="col-md-12">
<button type="submit" name="_preview" class="btn btn-primary">Preview</button>
{% if '_preview' in request.POST and not form.errors %}
<button type="submit" name="_apply" class="btn btn-primary">Apply</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -98,46 +98,6 @@
</tr>
</table>
</div>
{% if vc_members %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Virtual Chassis</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<th>Device</th>
<th>Position</th>
<th>Master</th>
<th>Priority</th>
</tr>
{% for vc_member in vc_members %}
<tr{% if vc_member == device %} class="info"{% endif %}>
<td>
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
</td>
<td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
<td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
<td>{{ vc_member.vc_priority|default:"" }}</td>
</tr>
{% endfor %}
</table>
<div class="panel-footer text-right">
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
</a>
<a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
</a>
{% endif %}
{% if perms.dcim.delete_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
</a>
{% endif %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Management</strong>
@@ -379,48 +339,45 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th colspan="2">Installed Device</th>
<th></th>
</tr>
</thead>
<tbody>
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' %}
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{% if device_bays and perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<div class="pull-right">
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body component-list">
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' %}
{% empty %}
<tr>
<td colspan="4">No device bays defined</td>
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
<div class="panel-footer">
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
{% if perms.dcim.delete_devicebay %}
</form>
@@ -439,61 +396,66 @@
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
</div>
</div>
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>MAC Address</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' %}
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interfaceconnection %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% 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">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
{% if perms.dcim.change_interface and interfaces|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_interface and interfaces|length > 10 %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<table id="interfaces_table" class="table table-hover panel-body component-list">
<tr class="table-headings">
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<th></th>
{% endif %}
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>MAC Address</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' %}
{% empty %}
<tr>
<td colspan="8">No interfaces defined</td>
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interfaceconnection %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% 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">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
{% if perms.dcim.delete_interface %}
</form>
@@ -507,51 +469,58 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for csp in cs_ports %}
{% include 'dcim/inc/consoleserverport.html' %}
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">&mdash; No console server ports defined &mdash;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{% if cs_ports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<div class="pull-right">
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="clearfix"></div>
{% endif %}
{% endif %}
</div>
</div>
<table class="table table-hover panel-body component-list">
<tr class="table-headings">
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<th></th>
{% endif %}
<th>Name</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
{% for csp in cs_ports %}
{% include 'dcim/inc/consoleserverport.html' %}
{% empty %}
<tr>
<td colspan="4">No console server ports defined</td>
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
<div class="panel-footer">
{% if cs_ports and perms.dcim.change_consoleport %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
{% if perms.dcim.delete_consoleserverport %}
</form>
@@ -565,51 +534,58 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for po in power_outlets %}
{% include 'dcim/inc/poweroutlet.html' %}
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">&mdash; No power outlets defined &mdash;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{% if power_outlets and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<div class="pull-right">
{% if perms.dcim.change_poweroutlet and power_outlets|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="clearfix"></div>
{% endif %}
{% endif %}
</div>
</div>
<table class="table table-hover panel-body component-list">
<tr class="table-headings">
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<th></th>
{% endif %}
<th>Name</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
{% for po in power_outlets %}
{% include 'dcim/inc/poweroutlet.html' %}
{% empty %}
<tr>
<td colspan="4">No power outlets defined</td>
</tr> text-nowrap
{% endfor %}
</table>
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
<div class="panel-footer">
{% if power_outlets and perms.dcim.change_powerport %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
{% if perms.dcim.delete_poweroutlet %}
</form>

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -43,17 +43,23 @@
<h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
<a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
</li>
{% if perms.dcim.napalm_read %}
{% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
{% if device.status != 1 %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
{% elif not device.platform.napalm_driver %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
{% elif not device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
{% else %}
<li role="presentation" class="disabled"><a href="#">Status</a></li>
<li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#">Configuration</a></li>
{% include 'dcim/inc/device_napalm_tabs.html' %}
{% endif %}
{% endif %}
</ul>

View File

@@ -0,0 +1,15 @@
{% if not disabled_message %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
</li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
</li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
</li>
{% else %}
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
{% endif %}

View File

@@ -16,9 +16,4 @@
</ul>
</div>
{% endif %}
{% if perms.dcim.add_virtualchassis %}
<button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
</button>
{% endif %}
{% endblock %}

View File

@@ -5,7 +5,7 @@
</td>
{% endif %}
<td>
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay }}
</td>
{% if devicebay.installed_device %}
<td>
@@ -19,7 +19,7 @@
<span class="text-muted">Vacant</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

View File

@@ -4,6 +4,19 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>{{ title }}</strong>
<div class="pull-right">
{% if table.rows|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if table.rows|length > 10 %}
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' %}
<div class="panel-footer">

View File

@@ -0,0 +1,29 @@
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>

View File

@@ -98,18 +98,18 @@
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Disconnect">
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -1,28 +0,0 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.form_factor %}
{% render_field form.enabled %}
{% render_field form.lag %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% render_field form.mgmt_only %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
<div class="panel-body">
{% render_field form.mode %}
{% render_field form.site %}
{% render_field form.vlan_group %}
{% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %}
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -225,20 +225,12 @@
<table class="table table-hover panel-body">
<tr>
<th>Units</th>
<th>Tenant</th>
<th>Description</th>
<th></th>
</tr>
{% for resv in reservations %}
<tr>
<td>{{ resv.unit_list }}</td>
<td>
{% if resv.tenant %}
<a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
<td>
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small>

View File

@@ -45,9 +45,10 @@
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% include 'dcim/inc/filter_rack_group.html' %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">
@@ -22,34 +21,6 @@
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>
{% include 'dcim/inc/filter_rack_group.html' %}
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load tz %}
{% load helpers %}
{% block content %}
@@ -58,12 +57,6 @@
<strong>Site</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Status</td>
<td>
<span class="label label-{{ site.get_status_class }}">{{ site.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Region</td>
<td>
@@ -112,27 +105,6 @@
{% endif %}
</td>
</tr>
<tr>
<td>Time Zone</td>
<td>
{% if site.time_zone %}
{{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})<br />
<small class="text-muted">Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if site.description %}
{{ site.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="panel panel-default">

View File

@@ -7,11 +7,9 @@
<div class="panel-body">
{% 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 %}
</div>
</div>
<div class="panel panel-default">

View File

@@ -1,35 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block content %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h3>{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}</h3>
{% if membership_form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ membership_form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Add New Member</strong></div>
<div class="table panel-body">
{% render_form member_select_form %}
{% render_form membership_form %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
<button type="submit" name="_save" class="btn btn-primary">Save</button>
<button type="submit" name="_addanother" class="btn btn-primary">Add Another</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% endblock %}

View File

@@ -1,75 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block content %}
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
{% csrf_token %}
{{ pk_form.pk }}
{{ formset.management_form }}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
{% if vc_form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ vc_form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Virtual Chassis</strong></div>
<div class="table panel-body">
{% render_form vc_form %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Members</strong></div>
<table class="table panel-body">
<thead>
<tr>
<th>Device</th>
<th>Position</th>
<th>Priority</th>
<th></th>
</tr>
</thead>
<tbody>
{% for form in formset %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% with device=form.instance virtual_chassis=vc_form.instance %}
<tr>
<td>
<a href="{{ device.get_absolute_url }}">{{ device }}</a>
</td>
<td>{{ form.vc_position }}</td>
<td>{{ form.vc_priority }}</td>
<td>
{% if virtual_chassis.pk %}
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
{% endif %}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
{% if vc_form.instance.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,8 +0,0 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Remove Virtual Chassis Member?{% endblock %}
{% block message %}
<p>Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ device.virtual_chassis }}?</p>
{% endblock %}

View File

@@ -1,7 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load humanize %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -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' %}
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -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' %}
</div>
</div>
{% endblock %}

View File

@@ -1,7 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,7 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load humanize %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,7 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% load buttons %}
{% block content %}
<div class="pull-right">

Some files were not shown because too many files have changed in this diff Show More