mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-21 03:27:21 -06:00
Merge branch 'develop-2.3' into develop
This commit is contained in:
commit
22f17a1424
@ -112,3 +112,11 @@ 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.
|
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.
|
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.
|
||||||
|
@ -2,11 +2,12 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -19,7 +20,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
|
|||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -37,7 +38,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
|
|||||||
model = Provider
|
model = Provider
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -66,14 +67,15 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class CircuitSerializer(CustomFieldModelSerializer):
|
class CircuitSerializer(CustomFieldModelSerializer):
|
||||||
provider = NestedProviderSerializer()
|
provider = NestedProviderSerializer()
|
||||||
|
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
|
||||||
type = NestedCircuitTypeSerializer()
|
type = NestedCircuitTypeSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||||
'custom_fields',
|
'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -90,8 +92,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||||
'custom_fields',
|
'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,14 +3,13 @@ from __future__ import unicode_literals
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from circuits import filters
|
from circuits import filters
|
||||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||||
from extras.api.serializers import RenderedGraphSerializer
|
from extras.api.serializers import RenderedGraphSerializer
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +27,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
|||||||
# Providers
|
# Providers
|
||||||
#
|
#
|
||||||
|
|
||||||
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class ProviderViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Provider.objects.all()
|
queryset = Provider.objects.all()
|
||||||
serializer_class = serializers.ProviderSerializer
|
serializer_class = serializers.ProviderSerializer
|
||||||
write_serializer_class = serializers.WritableProviderSerializer
|
write_serializer_class = serializers.WritableProviderSerializer
|
||||||
@ -59,7 +58,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
|||||||
# Circuits
|
# Circuits
|
||||||
#
|
#
|
||||||
|
|
||||||
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class CircuitViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||||
serializer_class = serializers.CircuitSerializer
|
serializer_class = serializers.CircuitSerializer
|
||||||
write_serializer_class = serializers.WritableCircuitSerializer
|
write_serializer_class = serializers.WritableCircuitSerializer
|
||||||
@ -70,7 +69,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Circuit Terminations
|
# Circuit Terminations
|
||||||
#
|
#
|
||||||
|
|
||||||
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
|
class CircuitTerminationViewSet(ModelViewSet):
|
||||||
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
||||||
serializer_class = serializers.CircuitTerminationSerializer
|
serializer_class = serializers.CircuitTerminationSerializer
|
||||||
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
||||||
|
@ -1,6 +1,22 @@
|
|||||||
from __future__ import unicode_literals
|
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
|
# CircuitTermination sides
|
||||||
TERM_SIDE_A = 'A'
|
TERM_SIDE_A = 'A'
|
||||||
TERM_SIDE_Z = 'Z'
|
TERM_SIDE_Z = 'Z'
|
||||||
|
@ -7,6 +7,7 @@ from dcim.models import Site
|
|||||||
from extras.filters import CustomFieldFilterSet
|
from extras.filters import CustomFieldFilterSet
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.filters import NumericInFilter
|
from utilities.filters import NumericInFilter
|
||||||
|
from .constants import CIRCUIT_STATUS_CHOICES
|
||||||
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
||||||
|
|
||||||
|
|
||||||
@ -77,6 +78,10 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Circuit type (slug)',
|
label='Circuit type (slug)',
|
||||||
)
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=CIRCUIT_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
label='Tenant (ID)',
|
label='Tenant (ID)',
|
||||||
|
@ -8,9 +8,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
|
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
|
||||||
SmallTextarea, SlugField,
|
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||||
)
|
)
|
||||||
|
from .constants import CIRCUIT_STATUS_CHOICES
|
||||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
|
|
||||||
|
|
||||||
@ -105,7 +106,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||||
'comments',
|
'comments',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
@ -132,6 +133,11 @@ class CircuitCSVForm(forms.ModelForm):
|
|||||||
'invalid_choice': 'Invalid circuit type.'
|
'invalid_choice': 'Invalid circuit type.'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
status = CSVChoiceField(
|
||||||
|
choices=CIRCUIT_STATUS_CHOICES,
|
||||||
|
required=False,
|
||||||
|
help_text='Operational status'
|
||||||
|
)
|
||||||
tenant = forms.ModelChoiceField(
|
tenant = forms.ModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -144,13 +150,16 @@ class CircuitCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
fields = [
|
||||||
|
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||||
provider = forms.ModelChoiceField(queryset=Provider.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)
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||||
description = forms.CharField(max_length=100, required=False)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
@ -160,6 +169,13 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
|
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):
|
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Circuit
|
model = Circuit
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -171,6 +187,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug'
|
to_field_name='slug'
|
||||||
)
|
)
|
||||||
|
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
|
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# -*- 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),
|
||||||
|
),
|
||||||
|
]
|
@ -5,11 +5,12 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
|
|
||||||
|
from dcim.constants import STATUS_CLASSES
|
||||||
from dcim.fields import ASNField
|
from dcim.fields import ASNField
|
||||||
from extras.models import CustomFieldModel, CustomFieldValue
|
from extras.models import CustomFieldModel, CustomFieldValue
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.models import CreatedUpdatedModel
|
from utilities.models import CreatedUpdatedModel
|
||||||
from .constants import *
|
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
@ -89,6 +90,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
||||||
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
||||||
type = models.ForeignKey('CircuitType', 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)
|
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')
|
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)')
|
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||||
@ -96,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
comments = models.TextField(blank=True)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
|
|
||||||
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
csv_headers = [
|
||||||
|
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['provider', 'cid']
|
ordering = ['provider', 'cid']
|
||||||
@ -113,6 +117,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.cid,
|
self.cid,
|
||||||
self.provider.name,
|
self.provider.name,
|
||||||
self.type.name,
|
self.type.name,
|
||||||
|
self.get_status_display(),
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.install_date,
|
self.install_date,
|
||||||
self.commit_rate,
|
self.commit_rate,
|
||||||
@ -120,6 +125,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.comments,
|
self.comments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_status_class(self):
|
||||||
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
def _get_termination(self, side):
|
def _get_termination(self, side):
|
||||||
for ct in self.terminations.all():
|
for ct in self.terminations.all():
|
||||||
if ct.term_side == side:
|
if ct.term_side == side:
|
||||||
|
@ -14,6 +14,10 @@ CIRCUITTYPE_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STATUS_LABEL = """
|
||||||
|
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationColumn(tables.Column):
|
class CircuitTerminationColumn(tables.Column):
|
||||||
|
|
||||||
@ -76,10 +80,11 @@ class CircuitTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
cid = tables.LinkColumn(verbose_name='ID')
|
cid = tables.LinkColumn(verbose_name='ID')
|
||||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
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)
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
|
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
|
||||||
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
|
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||||
|
@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:provider-list')
|
url = reverse('circuits-api:provider-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Provider.objects.count(), 4)
|
self.assertEqual(Provider.objects.count(), 4)
|
||||||
@ -77,6 +77,32 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(provider4.name, data['name'])
|
self.assertEqual(provider4.name, data['name'])
|
||||||
self.assertEqual(provider4.slug, data['slug'])
|
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):
|
def test_update_provider(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -85,7 +111,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Provider.objects.count(), 3)
|
self.assertEqual(Provider.objects.count(), 3)
|
||||||
@ -136,7 +162,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittype-list')
|
url = reverse('circuits-api:circuittype-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(CircuitType.objects.count(), 4)
|
self.assertEqual(CircuitType.objects.count(), 4)
|
||||||
@ -152,7 +178,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(CircuitType.objects.count(), 3)
|
self.assertEqual(CircuitType.objects.count(), 3)
|
||||||
@ -208,7 +234,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuit-list')
|
url = reverse('circuits-api:circuit-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Circuit.objects.count(), 4)
|
self.assertEqual(Circuit.objects.count(), 4)
|
||||||
@ -217,6 +243,35 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(circuit4.provider_id, data['provider'])
|
self.assertEqual(circuit4.provider_id, data['provider'])
|
||||||
self.assertEqual(circuit4.type_id, data['type'])
|
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):
|
def test_update_circuit(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -226,7 +281,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Circuit.objects.count(), 3)
|
self.assertEqual(Circuit.objects.count(), 3)
|
||||||
@ -293,7 +348,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittermination-list')
|
url = reverse('circuits-api:circuittermination-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(CircuitTermination.objects.count(), 4)
|
self.assertEqual(CircuitTermination.objects.count(), 4)
|
||||||
@ -313,7 +368,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||||
|
@ -7,19 +7,20 @@ from rest_framework.validators import UniqueTogetherValidator
|
|||||||
|
|
||||||
from circuits.models import Circuit, CircuitTermination
|
from circuits.models import Circuit, CircuitTermination
|
||||||
from dcim.constants import (
|
from dcim.constants import (
|
||||||
CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
|
CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
|
||||||
RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||||
)
|
)
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
RackReservation, RackRole, Region, Site,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress, VLAN
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
from users.api.serializers import NestedUserSerializer
|
||||||
|
from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
|
|
||||||
|
|
||||||
@ -55,15 +56,18 @@ class WritableRegionSerializer(ValidatedModelSerializer):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class SiteSerializer(CustomFieldModelSerializer):
|
class SiteSerializer(CustomFieldModelSerializer):
|
||||||
|
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
|
||||||
region = NestedRegionSerializer()
|
region = NestedRegionSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
|
time_zone = TimeZoneField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Site
|
model = Site
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
|
||||||
|
'count_circuits',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -76,12 +80,14 @@ class NestedSiteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class WritableSiteSerializer(CustomFieldModelSerializer):
|
class WritableSiteSerializer(CustomFieldModelSerializer):
|
||||||
|
time_zone = TimeZoneField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Site
|
model = Site
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
|
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -147,7 +153,7 @@ class RackSerializer(CustomFieldModelSerializer):
|
|||||||
model = Rack
|
model = Rack
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
|
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
|
||||||
'u_height', 'desc_units', 'comments', 'custom_fields',
|
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -165,7 +171,7 @@ class WritableRackSerializer(CustomFieldModelSerializer):
|
|||||||
model = Rack
|
model = Rack
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
||||||
'desc_units', 'comments', 'custom_fields',
|
'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||||
# prevents facility_id from being interpreted as a required field.
|
# prevents facility_id from being interpreted as a required field.
|
||||||
@ -215,10 +221,12 @@ class RackUnitSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class RackReservationSerializer(serializers.ModelSerializer):
|
class RackReservationSerializer(serializers.ModelSerializer):
|
||||||
rack = NestedRackSerializer()
|
rack = NestedRackSerializer()
|
||||||
|
user = NestedUserSerializer()
|
||||||
|
tenant = NestedTenantSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
|
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
|
||||||
|
|
||||||
|
|
||||||
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
||||||
@ -423,11 +431,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
|||||||
# Platforms
|
# Platforms
|
||||||
#
|
#
|
||||||
|
|
||||||
class PlatformSerializer(ValidatedModelSerializer):
|
class PlatformSerializer(serializers.ModelSerializer):
|
||||||
|
manufacturer = NestedManufacturerSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
|
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||||
|
|
||||||
|
|
||||||
class NestedPlatformSerializer(serializers.ModelSerializer):
|
class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||||
@ -438,6 +447,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['id', 'url', 'name', 'slug']
|
fields = ['id', 'url', 'name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class WritablePlatformSerializer(ValidatedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Platform
|
||||||
|
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Devices
|
# Devices
|
||||||
#
|
#
|
||||||
@ -460,6 +476,16 @@ class NestedClusterSerializer(serializers.ModelSerializer):
|
|||||||
fields = ['id', 'url', 'name']
|
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):
|
class DeviceSerializer(CustomFieldModelSerializer):
|
||||||
device_type = NestedDeviceTypeSerializer()
|
device_type = NestedDeviceTypeSerializer()
|
||||||
device_role = NestedDeviceRoleSerializer()
|
device_role = NestedDeviceRoleSerializer()
|
||||||
@ -468,19 +494,21 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
|||||||
site = NestedSiteSerializer()
|
site = NestedSiteSerializer()
|
||||||
rack = NestedRackSerializer()
|
rack = NestedRackSerializer()
|
||||||
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
||||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
|
||||||
primary_ip = DeviceIPAddressSerializer()
|
primary_ip = DeviceIPAddressSerializer()
|
||||||
primary_ip4 = DeviceIPAddressSerializer()
|
primary_ip4 = DeviceIPAddressSerializer()
|
||||||
primary_ip6 = DeviceIPAddressSerializer()
|
primary_ip6 = DeviceIPAddressSerializer()
|
||||||
parent_device = serializers.SerializerMethodField()
|
parent_device = serializers.SerializerMethodField()
|
||||||
cluster = NestedClusterSerializer()
|
cluster = NestedClusterSerializer()
|
||||||
|
virtual_chassis = DeviceVirtualChassisSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'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',
|
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||||
'cluster', 'comments', 'custom_fields',
|
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_parent_device(self, obj):
|
def get_parent_device(self, obj):
|
||||||
@ -500,7 +528,8 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
|
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||||
|
'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
@ -628,6 +657,15 @@ 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):
|
class InterfaceSerializer(serializers.ModelSerializer):
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||||
@ -635,12 +673,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
|||||||
is_connected = serializers.SerializerMethodField(read_only=True)
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
interface_connection = serializers.SerializerMethodField(read_only=True)
|
interface_connection = serializers.SerializerMethodField(read_only=True)
|
||||||
circuit_termination = InterfaceCircuitTerminationSerializer()
|
circuit_termination = InterfaceCircuitTerminationSerializer()
|
||||||
|
untagged_vlan = InterfaceVLANSerializer()
|
||||||
|
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
|
||||||
|
tagged_vlans = InterfaceVLANSerializer(many=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||||
'is_connected', 'interface_connection', 'circuit_termination',
|
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_is_connected(self, obj):
|
def get_is_connected(self, obj):
|
||||||
@ -685,8 +726,23 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
'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
|
# Device bays
|
||||||
@ -771,3 +827,30 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = InterfaceConnection
|
model = InterfaceConnection
|
||||||
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
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']
|
||||||
|
@ -60,6 +60,9 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
|
|||||||
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
||||||
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||||
|
|
||||||
|
# Virtual chassis
|
||||||
|
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||||
|
|
||||||
|
@ -3,26 +3,25 @@ from __future__ import unicode_literals
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
|
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||||
|
|
||||||
from dcim import filters
|
from dcim import filters
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
RackReservation, RackRole, Region, Site,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
from extras.api.serializers import RenderedGraphSerializer
|
from extras.api.serializers import RenderedGraphSerializer
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||||
from utilities.api import (
|
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
|
||||||
IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin,
|
|
||||||
)
|
|
||||||
from . import serializers
|
from . import serializers
|
||||||
from .exceptions import MissingFilterException
|
from .exceptions import MissingFilterException
|
||||||
|
|
||||||
@ -47,7 +46,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
|||||||
# Regions
|
# Regions
|
||||||
#
|
#
|
||||||
|
|
||||||
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
class RegionViewSet(ModelViewSet):
|
||||||
queryset = Region.objects.all()
|
queryset = Region.objects.all()
|
||||||
serializer_class = serializers.RegionSerializer
|
serializer_class = serializers.RegionSerializer
|
||||||
write_serializer_class = serializers.WritableRegionSerializer
|
write_serializer_class = serializers.WritableRegionSerializer
|
||||||
@ -58,7 +57,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# Sites
|
# Sites
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class SiteViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Site.objects.select_related('region', 'tenant')
|
queryset = Site.objects.select_related('region', 'tenant')
|
||||||
serializer_class = serializers.SiteSerializer
|
serializer_class = serializers.SiteSerializer
|
||||||
write_serializer_class = serializers.WritableSiteSerializer
|
write_serializer_class = serializers.WritableSiteSerializer
|
||||||
@ -79,7 +78,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Rack groups
|
# Rack groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
class RackGroupViewSet(ModelViewSet):
|
||||||
queryset = RackGroup.objects.select_related('site')
|
queryset = RackGroup.objects.select_related('site')
|
||||||
serializer_class = serializers.RackGroupSerializer
|
serializer_class = serializers.RackGroupSerializer
|
||||||
write_serializer_class = serializers.WritableRackGroupSerializer
|
write_serializer_class = serializers.WritableRackGroupSerializer
|
||||||
@ -100,7 +99,7 @@ class RackRoleViewSet(ModelViewSet):
|
|||||||
# Racks
|
# Racks
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class RackViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||||
serializer_class = serializers.RackSerializer
|
serializer_class = serializers.RackSerializer
|
||||||
write_serializer_class = serializers.WritableRackSerializer
|
write_serializer_class = serializers.WritableRackSerializer
|
||||||
@ -131,8 +130,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Rack reservations
|
# Rack reservations
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
|
class RackReservationViewSet(ModelViewSet):
|
||||||
queryset = RackReservation.objects.select_related('rack')
|
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
|
||||||
serializer_class = serializers.RackReservationSerializer
|
serializer_class = serializers.RackReservationSerializer
|
||||||
write_serializer_class = serializers.WritableRackReservationSerializer
|
write_serializer_class = serializers.WritableRackReservationSerializer
|
||||||
filter_class = filters.RackReservationFilter
|
filter_class = filters.RackReservationFilter
|
||||||
@ -156,7 +155,7 @@ class ManufacturerViewSet(ModelViewSet):
|
|||||||
# Device types
|
# Device types
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||||
queryset = DeviceType.objects.select_related('manufacturer')
|
queryset = DeviceType.objects.select_related('manufacturer')
|
||||||
serializer_class = serializers.DeviceTypeSerializer
|
serializer_class = serializers.DeviceTypeSerializer
|
||||||
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
||||||
@ -167,42 +166,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Device type components
|
# Device type components
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsolePortTemplateViewSet(ModelViewSet):
|
||||||
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
||||||
filter_class = filters.ConsolePortTemplateFilter
|
filter_class = filters.ConsolePortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsoleServerPortTemplateViewSet(ModelViewSet):
|
||||||
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
||||||
filter_class = filters.ConsoleServerPortTemplateFilter
|
filter_class = filters.ConsoleServerPortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerPortTemplateViewSet(ModelViewSet):
|
||||||
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.PowerPortTemplateSerializer
|
serializer_class = serializers.PowerPortTemplateSerializer
|
||||||
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
||||||
filter_class = filters.PowerPortTemplateFilter
|
filter_class = filters.PowerPortTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerOutletTemplateViewSet(ModelViewSet):
|
||||||
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||||
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
||||||
filter_class = filters.PowerOutletTemplateFilter
|
filter_class = filters.PowerOutletTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceTemplateViewSet(ModelViewSet):
|
||||||
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.InterfaceTemplateSerializer
|
serializer_class = serializers.InterfaceTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
||||||
filter_class = filters.InterfaceTemplateFilter
|
filter_class = filters.InterfaceTemplateFilter
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class DeviceBayTemplateViewSet(ModelViewSet):
|
||||||
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
||||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||||
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
||||||
@ -226,6 +225,7 @@ class DeviceRoleViewSet(ModelViewSet):
|
|||||||
class PlatformViewSet(ModelViewSet):
|
class PlatformViewSet(ModelViewSet):
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.all()
|
||||||
serializer_class = serializers.PlatformSerializer
|
serializer_class = serializers.PlatformSerializer
|
||||||
|
write_serializer_class = serializers.WritablePlatformSerializer
|
||||||
filter_class = filters.PlatformFilter
|
filter_class = filters.PlatformFilter
|
||||||
|
|
||||||
|
|
||||||
@ -233,9 +233,10 @@ class PlatformViewSet(ModelViewSet):
|
|||||||
# Devices
|
# Devices
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class DeviceViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Device.objects.select_related(
|
queryset = Device.objects.select_related(
|
||||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||||
|
'virtual_chassis__master',
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||||
)
|
)
|
||||||
@ -263,12 +264,7 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
import napalm
|
import napalm
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
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
|
# Validate the configured driver
|
||||||
try:
|
try:
|
||||||
@ -316,35 +312,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Device components
|
# Device components
|
||||||
#
|
#
|
||||||
|
|
||||||
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsolePortViewSet(ModelViewSet):
|
||||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||||
serializer_class = serializers.ConsolePortSerializer
|
serializer_class = serializers.ConsolePortSerializer
|
||||||
write_serializer_class = serializers.WritableConsolePortSerializer
|
write_serializer_class = serializers.WritableConsolePortSerializer
|
||||||
filter_class = filters.ConsolePortFilter
|
filter_class = filters.ConsolePortFilter
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
class ConsoleServerPortViewSet(ModelViewSet):
|
||||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||||
serializer_class = serializers.ConsoleServerPortSerializer
|
serializer_class = serializers.ConsoleServerPortSerializer
|
||||||
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
||||||
filter_class = filters.ConsoleServerPortFilter
|
filter_class = filters.ConsoleServerPortFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerPortViewSet(ModelViewSet):
|
||||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||||
serializer_class = serializers.PowerPortSerializer
|
serializer_class = serializers.PowerPortSerializer
|
||||||
write_serializer_class = serializers.WritablePowerPortSerializer
|
write_serializer_class = serializers.WritablePowerPortSerializer
|
||||||
filter_class = filters.PowerPortFilter
|
filter_class = filters.PowerPortFilter
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
|
class PowerOutletViewSet(ModelViewSet):
|
||||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||||
serializer_class = serializers.PowerOutletSerializer
|
serializer_class = serializers.PowerOutletSerializer
|
||||||
write_serializer_class = serializers.WritablePowerOutletSerializer
|
write_serializer_class = serializers.WritablePowerOutletSerializer
|
||||||
filter_class = filters.PowerOutletFilter
|
filter_class = filters.PowerOutletFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceViewSet(ModelViewSet):
|
||||||
queryset = Interface.objects.select_related('device')
|
queryset = Interface.objects.select_related('device')
|
||||||
serializer_class = serializers.InterfaceSerializer
|
serializer_class = serializers.InterfaceSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||||
@ -361,14 +357,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
|
class DeviceBayViewSet(ModelViewSet):
|
||||||
queryset = DeviceBay.objects.select_related('installed_device')
|
queryset = DeviceBay.objects.select_related('installed_device')
|
||||||
serializer_class = serializers.DeviceBaySerializer
|
serializer_class = serializers.DeviceBaySerializer
|
||||||
write_serializer_class = serializers.WritableDeviceBaySerializer
|
write_serializer_class = serializers.WritableDeviceBaySerializer
|
||||||
filter_class = filters.DeviceBayFilter
|
filter_class = filters.DeviceBayFilter
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
|
class InventoryItemViewSet(ModelViewSet):
|
||||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||||
serializer_class = serializers.InventoryItemSerializer
|
serializer_class = serializers.InventoryItemSerializer
|
||||||
write_serializer_class = serializers.WritableInventoryItemSerializer
|
write_serializer_class = serializers.WritableInventoryItemSerializer
|
||||||
@ -391,13 +387,23 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
|||||||
filter_class = filters.PowerConnectionFilter
|
filter_class = filters.PowerConnectionFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceConnectionViewSet(ModelViewSet):
|
||||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||||
serializer_class = serializers.InterfaceConnectionSerializer
|
serializer_class = serializers.InterfaceConnectionSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||||
filter_class = filters.InterfaceConnectionFilter
|
filter_class = filters.InterfaceConnectionFilter
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Virtual chassis
|
||||||
|
#
|
||||||
|
|
||||||
|
class VirtualChassisViewSet(ModelViewSet):
|
||||||
|
queryset = VirtualChassis.objects.all()
|
||||||
|
serializer_class = serializers.VirtualChassisSerializer
|
||||||
|
write_serializer_class = serializers.WritableVirtualChassisSerializer
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
#
|
#
|
||||||
|
@ -6,3 +6,6 @@ from django.apps import AppConfig
|
|||||||
class DCIMConfig(AppConfig):
|
class DCIMConfig(AppConfig):
|
||||||
name = "dcim"
|
name = "dcim"
|
||||||
verbose_name = "DCIM"
|
verbose_name = "DCIM"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import dcim.signals
|
||||||
|
@ -193,24 +193,43 @@ WIRELESS_IFACE_TYPES = [
|
|||||||
|
|
||||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||||
|
|
||||||
# Device statuses
|
IFACE_MODE_ACCESS = 100
|
||||||
STATUS_OFFLINE = 0
|
IFACE_MODE_TAGGED = 200
|
||||||
STATUS_ACTIVE = 1
|
IFACE_MODE_TAGGED_ALL = 300
|
||||||
STATUS_PLANNED = 2
|
IFACE_MODE_CHOICES = [
|
||||||
STATUS_STAGED = 3
|
[IFACE_MODE_ACCESS, 'Access'],
|
||||||
STATUS_FAILED = 4
|
[IFACE_MODE_TAGGED, 'Tagged'],
|
||||||
STATUS_INVENTORY = 5
|
[IFACE_MODE_TAGGED_ALL, 'Tagged All'],
|
||||||
STATUS_CHOICES = [
|
|
||||||
[STATUS_ACTIVE, 'Active'],
|
|
||||||
[STATUS_OFFLINE, 'Offline'],
|
|
||||||
[STATUS_PLANNED, 'Planned'],
|
|
||||||
[STATUS_STAGED, 'Staged'],
|
|
||||||
[STATUS_FAILED, 'Failed'],
|
|
||||||
[STATUS_INVENTORY, 'Inventory'],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Bootstrap CSS classes for device stasuses
|
# Device statuses
|
||||||
DEVICE_STATUS_CLASSES = {
|
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'],
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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 = {
|
||||||
0: 'warning',
|
0: 'warning',
|
||||||
1: 'success',
|
1: 'success',
|
||||||
2: 'info',
|
2: 'info',
|
||||||
|
@ -11,13 +11,14 @@ from tenancy.models import Tenant
|
|||||||
from utilities.filters import NullableCharFieldFilter, NumericInFilter
|
from utilities.filters import NullableCharFieldFilter, NumericInFilter
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
from .constants import (
|
from .constants import (
|
||||||
IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, STATUS_CHOICES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
|
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
|
||||||
|
WIRELESS_IFACE_TYPES,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||||
RackReservation, RackRole, Region, Site,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +58,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=SITE_STATUS_CHOICES,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
label='Region (ID)',
|
label='Region (ID)',
|
||||||
@ -88,6 +93,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
qs_filter = (
|
qs_filter = (
|
||||||
Q(name__icontains=value) |
|
Q(name__icontains=value) |
|
||||||
Q(facility__icontains=value) |
|
Q(facility__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
Q(physical_address__icontains=value) |
|
Q(physical_address__icontains=value) |
|
||||||
Q(shipping_address__icontains=value) |
|
Q(shipping_address__icontains=value) |
|
||||||
Q(contact_name__icontains=value) |
|
Q(contact_name__icontains=value) |
|
||||||
@ -221,6 +227,16 @@ class RackReservationFilter(django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Group',
|
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(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
label='User (ID)',
|
label='User (ID)',
|
||||||
@ -347,6 +363,17 @@ class DeviceRoleFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class PlatformFilter(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:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
@ -438,7 +465,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
label='Device model (slug)',
|
label='Device model (slug)',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=STATUS_CHOICES,
|
choices=DEVICE_STATUS_CHOICES,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
is_full_depth = django_filters.BooleanFilter(
|
is_full_depth = django_filters.BooleanFilter(
|
||||||
@ -465,6 +492,11 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='_has_primary_ip',
|
method='_has_primary_ip',
|
||||||
label='Has a 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:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
@ -580,8 +612,9 @@ class InterfaceFilter(django_filters.FilterSet):
|
|||||||
def filter_device(self, queryset, name, value):
|
def filter_device(self, queryset, name, value):
|
||||||
try:
|
try:
|
||||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
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
|
ordering = device.device_type.interface_ordering
|
||||||
return queryset.filter(device=device).order_naturally(ordering)
|
return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
|
||||||
except Device.DoesNotExist:
|
except Device.DoesNotExist:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
@ -650,6 +683,48 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualChassisFilter(django_filters.FilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label='Search',
|
||||||
|
)
|
||||||
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__site',
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
label='Site (ID)',
|
||||||
|
)
|
||||||
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__site__slug',
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Site name (slug)',
|
||||||
|
)
|
||||||
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__tenant',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
label='Tenant (ID)',
|
||||||
|
)
|
||||||
|
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
name='master__tenant__slug',
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Tenant (slug)',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VirtualChassis
|
||||||
|
fields = ['domain']
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
qs_filter = (
|
||||||
|
Q(master__name__icontains=value) |
|
||||||
|
Q(domain__icontains=value)
|
||||||
|
)
|
||||||
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||||
site = django_filters.CharFilter(
|
site = django_filters.CharFilter(
|
||||||
method='filter_site',
|
method='filter_site',
|
||||||
|
@ -7,29 +7,32 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.postgres.forms.array import SimpleArrayField
|
from django.contrib.postgres.forms.array import SimpleArrayField
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from mptt.forms import TreeNodeChoiceField
|
from mptt.forms import TreeNodeChoiceField
|
||||||
|
from timezone_field import TimeZoneFormField
|
||||||
|
|
||||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress, VLAN, VLANGroup
|
||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
|
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
|
||||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
|
||||||
SlugField, FilterTreeNodeMultipleChoiceField,
|
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
|
||||||
|
SmallTextarea, SlugField,
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES,
|
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
|
||||||
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, STATUS_CHOICES,
|
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
|
||||||
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_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,
|
||||||
)
|
)
|
||||||
from .formfields import MACAddressFormField
|
from .formfields import MACAddressFormField
|
||||||
from .models import (
|
from .models import (
|
||||||
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
|
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
|
||||||
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||||
RackRole, Region, Site,
|
RackRole, Region, Site, VirtualChassis
|
||||||
)
|
)
|
||||||
|
|
||||||
DEVICE_BY_PK_RE = '{\d+\}'
|
DEVICE_BY_PK_RE = '{\d+\}'
|
||||||
@ -47,6 +50,14 @@ def get_device_by_name_or_pk(name):
|
|||||||
return device
|
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
|
# Regions
|
||||||
#
|
#
|
||||||
@ -96,8 +107,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Site
|
model = Site
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
|
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
|
||||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
|
||||||
|
'comments',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||||
@ -113,6 +125,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
|
|
||||||
|
|
||||||
class SiteCSVForm(forms.ModelForm):
|
class SiteCSVForm(forms.ModelForm):
|
||||||
|
status = CSVChoiceField(
|
||||||
|
choices=DEVICE_STATUS_CHOICES,
|
||||||
|
required=False,
|
||||||
|
help_text='Operational status'
|
||||||
|
)
|
||||||
region = forms.ModelChoiceField(
|
region = forms.ModelChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -144,17 +161,28 @@ class SiteCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
|
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)
|
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.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')
|
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:
|
class Meta:
|
||||||
nullable_fields = ['region', 'tenant', 'asn']
|
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]
|
||||||
|
|
||||||
|
|
||||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Site
|
model = Site
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
|
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
|
||||||
region = FilterTreeNodeMultipleChoiceField(
|
region = FilterTreeNodeMultipleChoiceField(
|
||||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
@ -372,13 +400,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
# Rack reservations
|
# Rack reservations
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackReservationForm(BootstrapMixin, forms.ModelForm):
|
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||||
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
|
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
|
||||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
|
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ['units', 'user', 'description']
|
fields = ['units', 'user', 'tenant_group', 'tenant', 'description']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -408,11 +436,17 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
|
|||||||
label='Rack group',
|
label='Rack group',
|
||||||
null_label='-- None --'
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
|
tenant = FilterChoiceField(
|
||||||
|
queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
|
||||||
|
to_field_name='slug',
|
||||||
|
null_label='-- None --'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
|
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
|
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)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -661,7 +695,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
|
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||||
|
|
||||||
|
|
||||||
class PlatformCSVForm(forms.ModelForm):
|
class PlatformCSVForm(forms.ModelForm):
|
||||||
@ -672,6 +706,7 @@ class PlatformCSVForm(forms.ModelForm):
|
|||||||
fields = Platform.csv_headers
|
fields = Platform.csv_headers
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': 'Platform name',
|
'name': 'Platform name',
|
||||||
|
'manufacturer': 'Manufacturer name',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -757,32 +792,35 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||||
for family in [4, 6]:
|
for family in [4, 6]:
|
||||||
ip_choices = [(None, '---------')]
|
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
|
# Collect interface IPs
|
||||||
interface_ips = IPAddress.objects.select_related('interface').filter(
|
interface_ips = IPAddress.objects.select_related('interface').filter(
|
||||||
family=family, interface__device=self.instance
|
family=family, interface_id__in=interface_ids
|
||||||
)
|
)
|
||||||
if interface_ips:
|
if interface_ips:
|
||||||
ip_choices.append(
|
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||||
('Interface IPs', [
|
ip_choices.append(('Interface IPs', ip_list))
|
||||||
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
|
|
||||||
])
|
|
||||||
)
|
|
||||||
# Collect NAT IPs
|
# Collect NAT IPs
|
||||||
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
|
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
|
||||||
family=family, nat_inside__interface__device=self.instance
|
family=family, nat_inside__interface__in=interface_ids
|
||||||
)
|
)
|
||||||
if nat_ips:
|
if nat_ips:
|
||||||
ip_choices.append(
|
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
|
||||||
('NAT IPs', [
|
ip_choices.append(('NAT IPs', ip_list))
|
||||||
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
|
|
||||||
])
|
|
||||||
)
|
|
||||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
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
|
# 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.
|
# can be flipped from one face to another.
|
||||||
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
|
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:
|
else:
|
||||||
|
|
||||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||||
@ -795,10 +833,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
pk = self.instance.pk if self.instance.pk else None
|
pk = self.instance.pk if self.instance.pk else None
|
||||||
try:
|
try:
|
||||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
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)
|
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||||
elif self.initial.get('rack') and str(self.initial.get('face')):
|
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)
|
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||||
else:
|
else:
|
||||||
position_choices = []
|
position_choices = []
|
||||||
@ -858,8 +896,8 @@ class BaseDeviceCSVForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
status = CSVChoiceField(
|
status = CSVChoiceField(
|
||||||
choices=STATUS_CHOICES,
|
choices=DEVICE_STATUS_CHOICES,
|
||||||
help_text='Operational status of device'
|
help_text='Operational status'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -995,7 +1033,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
|
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
|
||||||
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
|
status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='')
|
||||||
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -1006,7 +1044,7 @@ def device_status_choices():
|
|||||||
status_counts = {}
|
status_counts = {}
|
||||||
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||||
status_counts[status['status']] = status['count']
|
status_counts[status['status']] = status['count']
|
||||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
|
||||||
|
|
||||||
|
|
||||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
@ -1333,6 +1371,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleServerPortBulkRenameForm(BulkRenameForm):
|
||||||
|
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
|
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
@ -1594,6 +1636,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PowerOutletBulkRenameForm(BulkRenameForm):
|
||||||
|
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
@ -1602,11 +1648,58 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
|||||||
# Interfaces
|
# Interfaces
|
||||||
#
|
#
|
||||||
|
|
||||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
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 Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
|
fields = [
|
||||||
|
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||||
|
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
|
||||||
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'device': forms.HiddenInput(),
|
'device': forms.HiddenInput(),
|
||||||
}
|
}
|
||||||
@ -1614,18 +1707,70 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(InterfaceForm, self).__init__(*args, **kwargs)
|
super(InterfaceForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit LAG choices to interfaces belonging to this device
|
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||||
if self.is_bound:
|
if self.is_bound:
|
||||||
|
device = Device.objects.get(pk=self.data['device'])
|
||||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||||
device_id=self.data['device'], form_factor=IFACE_FF_LAG
|
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
device = self.instance.device
|
||||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||||
device=self.instance.device, form_factor=IFACE_FF_LAG
|
device__in=[self.instance.device, self.instance.device.get_vc_master()], 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
|
||||||
|
|
||||||
class InterfaceCreateForm(ComponentForm):
|
# 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):
|
||||||
name_pattern = ExpandableNameField(label='Name')
|
name_pattern = ExpandableNameField(label='Name')
|
||||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||||
enabled = forms.BooleanField(required=False)
|
enabled = forms.BooleanField(required=False)
|
||||||
@ -1638,6 +1783,51 @@ class InterfaceCreateForm(ComponentForm):
|
|||||||
help_text='This interface is used only for out-of-band management'
|
help_text='This interface is used only for out-of-band management'
|
||||||
)
|
)
|
||||||
description = forms.CharField(max_length=100, required=False)
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -1647,16 +1837,49 @@ class InterfaceCreateForm(ComponentForm):
|
|||||||
|
|
||||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit LAG choices to interfaces belonging to this device
|
# Limit LAG choices to interfaces belonging to this device (or its VC master)
|
||||||
if self.parent is not None:
|
if self.parent is not None:
|
||||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||||
device=self.parent, form_factor=IFACE_FF_LAG
|
device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['lag'].queryset = Interface.objects.none()
|
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
|
||||||
|
|
||||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
# 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):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
||||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||||
@ -1665,28 +1888,104 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
|||||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||||
description = forms.CharField(max_length=100, required=False)
|
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:
|
class Meta:
|
||||||
nullable_fields = ['lag', 'mtu', 'description']
|
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit LAG choices to interfaces which belong to the parent device.
|
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||||
device = None
|
device = None
|
||||||
if self.initial.get('device'):
|
if self.initial.get('device'):
|
||||||
try:
|
try:
|
||||||
device = Device.objects.get(pk=self.initial.get('device'))
|
device = Device.objects.get(pk=self.initial.get('device'))
|
||||||
except Device.DoesNotExist:
|
except Device.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
device = Device.objects.get(pk=self.data.get('device'))
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
pass
|
||||||
if device is not None:
|
if device is not None:
|
||||||
interface_ordering = device.device_type.interface_ordering
|
interface_ordering = device.device_type.interface_ordering
|
||||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||||
device=device, form_factor=IFACE_FF_LAG
|
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['lag'].choices = []
|
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
|
||||||
|
|
||||||
|
if self.is_bound:
|
||||||
|
filter_dict = {
|
||||||
|
'group_id': self.data.get('vlan_group') or None,
|
||||||
|
'site_id': self.data.get('site') or None,
|
||||||
|
}
|
||||||
|
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 InterfaceBulkRenameForm(BulkRenameForm):
|
||||||
|
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
|
|
||||||
class InterfaceBulkDisconnectForm(ConfirmationForm):
|
class InterfaceBulkDisconnectForm(ConfirmationForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
@ -1857,11 +2156,6 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
|
|||||||
return interface
|
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
|
# Device bays
|
||||||
#
|
#
|
||||||
@ -1900,6 +2194,10 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
|||||||
).exclude(pk=device_bay.device.pk)
|
).exclude(pk=device_bay.device.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||||
|
pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Connections
|
# Connections
|
||||||
#
|
#
|
||||||
@ -1972,3 +2270,128 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --'
|
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']
|
||||||
|
widgets = {
|
||||||
|
'master': SelectWithPK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseVCMemberFormSet(forms.BaseModelFormSet):
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super(BaseVCMemberFormSet, self).clean()
|
||||||
|
|
||||||
|
# Check for duplicate VC position values
|
||||||
|
vc_position_list = []
|
||||||
|
for form in self.forms:
|
||||||
|
vc_position = form.cleaned_data['vc_position']
|
||||||
|
if vc_position in vc_position_list:
|
||||||
|
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||||
|
form.add_error('vc_position', error_msg)
|
||||||
|
vc_position_list.append(vc_position)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceVCMembershipForm(forms.ModelForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Device
|
||||||
|
fields = ['vc_position', 'vc_priority']
|
||||||
|
labels = {
|
||||||
|
'vc_position': 'Position',
|
||||||
|
'vc_priority': 'Priority',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, validate_vc_position=False, *args, **kwargs):
|
||||||
|
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Require VC position (only required when the Device is a VirtualChassis member)
|
||||||
|
self.fields['vc_position'].required = True
|
||||||
|
|
||||||
|
# Validation of vc_position is optional. This is only required when adding a new member to an existing
|
||||||
|
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
|
||||||
|
self.validate_vc_position = validate_vc_position
|
||||||
|
|
||||||
|
def clean_vc_position(self):
|
||||||
|
vc_position = self.cleaned_data['vc_position']
|
||||||
|
|
||||||
|
if self.validate_vc_position:
|
||||||
|
conflicting_members = Device.objects.filter(
|
||||||
|
virtual_chassis=self.instance.virtual_chassis,
|
||||||
|
vc_position=vc_position
|
||||||
|
)
|
||||||
|
if conflicting_members.exists():
|
||||||
|
raise forms.ValidationError(
|
||||||
|
'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||||
|
)
|
||||||
|
|
||||||
|
return vc_position
|
||||||
|
|
||||||
|
|
||||||
|
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.filter(virtual_chassis__isnull=True),
|
||||||
|
chains=(
|
||||||
|
('site', 'site'),
|
||||||
|
('rack', 'rack'),
|
||||||
|
),
|
||||||
|
label='Device',
|
||||||
|
widget=APISelect(
|
||||||
|
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
|
||||||
|
display_field='display_name',
|
||||||
|
disabled_indicator='virtual_chassis'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
|
model = VirtualChassis
|
||||||
|
q = forms.CharField(required=False, label='Search')
|
||||||
|
site = FilterChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
)
|
||||||
|
tenant = FilterChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
null_label='-- None --',
|
||||||
|
)
|
||||||
|
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# -*- 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'),
|
||||||
|
),
|
||||||
|
]
|
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# -*- 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'),
|
||||||
|
),
|
||||||
|
]
|
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# -*- 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')]),
|
||||||
|
),
|
||||||
|
]
|
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# -*- 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,31 @@
|
|||||||
|
# -*- 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),
|
||||||
|
),
|
||||||
|
]
|
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.9 on 2018-02-21 14:41
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0054_site_status_timezone_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='virtualchassis',
|
||||||
|
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='virtualchassis',
|
||||||
|
name='master',
|
||||||
|
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
|
||||||
|
),
|
||||||
|
]
|
@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
from timezone_field import TimeZoneField
|
||||||
|
|
||||||
from circuits.models import Circuit
|
from circuits.models import Circuit
|
||||||
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
|
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
|
||||||
@ -79,10 +80,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(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)
|
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)
|
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
|
||||||
facility = models.CharField(max_length=50, blank=True)
|
facility = models.CharField(max_length=50, blank=True)
|
||||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
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)
|
physical_address = models.CharField(max_length=200, blank=True)
|
||||||
shipping_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)
|
contact_name = models.CharField(max_length=50, blank=True)
|
||||||
@ -95,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
objects = SiteManager()
|
objects = SiteManager()
|
||||||
|
|
||||||
csv_headers = [
|
csv_headers = [
|
||||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
|
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||||
'contact_phone', 'contact_email', 'comments',
|
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -112,10 +116,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return (
|
return (
|
||||||
self.name,
|
self.name,
|
||||||
self.slug,
|
self.slug,
|
||||||
|
self.get_status_display(),
|
||||||
self.region.name if self.region else None,
|
self.region.name if self.region else None,
|
||||||
self.tenant.name if self.tenant else None,
|
self.tenant.name if self.tenant else None,
|
||||||
self.facility,
|
self.facility,
|
||||||
self.asn,
|
self.asn,
|
||||||
|
self.time_zone,
|
||||||
|
self.description,
|
||||||
self.physical_address,
|
self.physical_address,
|
||||||
self.shipping_address,
|
self.shipping_address,
|
||||||
self.contact_name,
|
self.contact_name,
|
||||||
@ -124,6 +131,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
self.comments,
|
self.comments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_status_class(self):
|
||||||
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def count_prefixes(self):
|
def count_prefixes(self):
|
||||||
return self.prefixes.count()
|
return self.prefixes.count()
|
||||||
@ -431,6 +441,7 @@ class RackReservation(models.Model):
|
|||||||
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
||||||
units = ArrayField(models.PositiveSmallIntegerField())
|
units = ArrayField(models.PositiveSmallIntegerField())
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
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)
|
user = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||||
description = models.CharField(max_length=100)
|
description = models.CharField(max_length=100)
|
||||||
|
|
||||||
@ -785,18 +796,33 @@ class DeviceRole(models.Model):
|
|||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class Platform(models.Model):
|
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
|
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
||||||
specifying an remote procedure call (RPC) client.
|
specifying a NAPALM driver.
|
||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
|
manufacturer = models.ForeignKey(
|
||||||
help_text="The name of the NAPALM driver to use when interacting with devices.")
|
to='Manufacturer',
|
||||||
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
|
related_name='platforms',
|
||||||
verbose_name='Legacy RPC client')
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
csv_headers = ['name', 'slug', 'napalm_driver']
|
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
@ -811,6 +837,7 @@ class Platform(models.Model):
|
|||||||
return (
|
return (
|
||||||
self.name,
|
self.name,
|
||||||
self.slug,
|
self.slug,
|
||||||
|
self.manufacturer.name if self.manufacturer else None,
|
||||||
self.napalm_driver,
|
self.napalm_driver,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -851,7 +878,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
help_text='The lowest-numbered unit occupied by the device'
|
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')
|
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||||
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
|
||||||
primary_ip4 = models.OneToOneField(
|
primary_ip4 = models.OneToOneField(
|
||||||
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||||
verbose_name='Primary IPv4'
|
verbose_name='Primary IPv4'
|
||||||
@ -867,6 +894,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=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)
|
comments = models.TextField(blank=True)
|
||||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||||
images = GenericRelation(ImageAttachment)
|
images = GenericRelation(ImageAttachment)
|
||||||
@ -880,7 +924,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
unique_together = ['rack', 'position', 'face']
|
unique_together = [
|
||||||
|
['rack', 'position', 'face'],
|
||||||
|
['virtual_chassis', 'vc_position'],
|
||||||
|
]
|
||||||
permissions = (
|
permissions = (
|
||||||
('napalm_read', 'Read-only access to devices via NAPALM'),
|
('napalm_read', 'Read-only access to devices via NAPALM'),
|
||||||
('napalm_write', 'Read/write access to devices via NAPALM'),
|
('napalm_write', 'Read/write access to devices via NAPALM'),
|
||||||
@ -949,29 +996,36 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
except DeviceType.DoesNotExist:
|
except DeviceType.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Validate primary IPv4 address
|
# Validate primary IP addresses
|
||||||
if self.primary_ip4 and (
|
vc_interfaces = self.vc_interfaces.all()
|
||||||
self.primary_ip4.interface is None or
|
if self.primary_ip4:
|
||||||
self.primary_ip4.interface.device != self
|
if self.primary_ip4.interface in vc_interfaces:
|
||||||
) and (
|
pass
|
||||||
self.primary_ip4.nat_inside.interface is None or
|
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||||
self.primary_ip4.nat_inside.interface.device != self
|
pass
|
||||||
):
|
else:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
|
'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 IPv6 address
|
# Validate manufacturer/platform
|
||||||
if self.primary_ip6 and (
|
if self.device_type and self.platform:
|
||||||
self.primary_ip6.interface is None or
|
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||||
self.primary_ip6.interface.device != self
|
raise ValidationError({
|
||||||
) and (
|
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||||
self.primary_ip6.nat_inside.interface is None or
|
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||||
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)
|
# 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:
|
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||||
@ -979,6 +1033,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
|
'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):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
is_new = not bool(self.pk)
|
is_new = not bool(self.pk)
|
||||||
@ -1038,6 +1098,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
def display_name(self):
|
def display_name(self):
|
||||||
if self.name:
|
if self.name:
|
||||||
return 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'):
|
elif hasattr(self, 'device_type'):
|
||||||
return "{}".format(self.device_type)
|
return "{}".format(self.device_type)
|
||||||
return ""
|
return ""
|
||||||
@ -1062,6 +1124,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
else:
|
else:
|
||||||
return None
|
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):
|
def get_children(self):
|
||||||
"""
|
"""
|
||||||
Return the set of child Devices installed in DeviceBays within this Device.
|
Return the set of child Devices installed in DeviceBays within this Device.
|
||||||
@ -1069,7 +1148,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return Device.objects.filter(parent_bay__device=self.pk)
|
return Device.objects.filter(parent_bay__device=self.pk)
|
||||||
|
|
||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return DEVICE_STATUS_CLASSES[self.status]
|
return STATUS_CLASSES[self.status]
|
||||||
|
|
||||||
def get_rpc_client(self):
|
def get_rpc_client(self):
|
||||||
"""
|
"""
|
||||||
@ -1104,6 +1183,9 @@ class ConsolePort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return (
|
return (
|
||||||
self.cs_port.device.identifier if self.cs_port else None,
|
self.cs_port.device.identifier if self.cs_port else None,
|
||||||
@ -1144,6 +1226,9 @@ class ConsoleServerPort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Check that the parent device's DeviceType is a console server
|
# Check that the parent device's DeviceType is a console server
|
||||||
@ -1180,6 +1265,9 @@ class PowerPort(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return (
|
return (
|
||||||
self.power_outlet.device.identifier if self.power_outlet else None,
|
self.power_outlet.device.identifier if self.power_outlet else None,
|
||||||
@ -1220,6 +1308,9 @@ class PowerOutlet(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Check that the parent device's DeviceType is a PDU
|
# Check that the parent device's DeviceType is a PDU
|
||||||
@ -1275,6 +1366,24 @@ class Interface(models.Model):
|
|||||||
help_text="This interface is used only for out-of-band management"
|
help_text="This interface is used only for out-of-band management"
|
||||||
)
|
)
|
||||||
description = models.CharField(max_length=100, blank=True)
|
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()
|
objects = InterfaceQuerySet.as_manager()
|
||||||
|
|
||||||
@ -1285,6 +1394,9 @@ class Interface(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.parent.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Check that the parent device's DeviceType is a network device
|
# Check that the parent device's DeviceType is a network device
|
||||||
@ -1314,8 +1426,8 @@ class Interface(models.Model):
|
|||||||
"Disconnect the interface or choose a suitable form factor."
|
"Disconnect the interface or choose a suitable form factor."
|
||||||
})
|
})
|
||||||
|
|
||||||
# An interface's LAG must belong to the same device
|
# An interface's LAG must belong to the same device (or VC master)
|
||||||
if self.lag and self.lag.device != self.device:
|
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||||
self.lag.name, self.lag.device.name
|
self.lag.name, self.lag.device.name
|
||||||
@ -1336,6 +1448,13 @@ 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
|
@property
|
||||||
def parent(self):
|
def parent(self):
|
||||||
return self.device or self.virtual_machine
|
return self.device or self.virtual_machine
|
||||||
@ -1439,6 +1558,9 @@ class DeviceBay(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{} - {}'.format(self.device.name, self.name)
|
return '{} - {}'.format(self.device.name, self.name)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Validate that the parent Device can have DeviceBays
|
# Validate that the parent Device can have DeviceBays
|
||||||
@ -1488,6 +1610,9 @@ class InventoryItem(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.device.get_absolute_url()
|
||||||
|
|
||||||
def to_csv(self):
|
def to_csv(self):
|
||||||
return (
|
return (
|
||||||
self.device.name or '{' + self.device.pk + '}',
|
self.device.name or '{' + self.device.pk + '}',
|
||||||
@ -1499,3 +1624,42 @@ class InventoryItem(models.Model):
|
|||||||
self.discovered,
|
self.discovered,
|
||||||
self.description,
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['master']
|
||||||
|
verbose_name_plural = 'virtual chassis'
|
||||||
|
|
||||||
|
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."
|
||||||
|
})
|
||||||
|
23
netbox/dcim/signals.py
Normal file
23
netbox/dcim/signals.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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)
|
@ -9,6 +9,7 @@ from .models import (
|
|||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
|
||||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
|
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
|
||||||
|
VirtualChassis,
|
||||||
)
|
)
|
||||||
|
|
||||||
REGION_LINK = """
|
REGION_LINK = """
|
||||||
@ -113,7 +114,7 @@ DEVICE_ROLE = """
|
|||||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEVICE_STATUS = """
|
STATUS_LABEL = """
|
||||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -132,6 +133,12 @@ UTILIZATION_GRAPH = """
|
|||||||
{% utilization_graph value %}
|
{% 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
|
# Regions
|
||||||
@ -160,27 +167,13 @@ class RegionTable(BaseTable):
|
|||||||
class SiteTable(BaseTable):
|
class SiteTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
||||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Site
|
model = Site
|
||||||
fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
|
fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
|
||||||
|
|
||||||
|
|
||||||
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',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -270,6 +263,7 @@ class RackImportTable(BaseTable):
|
|||||||
|
|
||||||
class RackReservationTable(BaseTable):
|
class RackReservationTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
|
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@ -278,7 +272,7 @@ class RackReservationTable(BaseTable):
|
|||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -289,13 +283,14 @@ class ManufacturerTable(BaseTable):
|
|||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
name = tables.LinkColumn(verbose_name='Name')
|
||||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||||
|
platform_count = tables.Column(verbose_name='Platforms')
|
||||||
slug = tables.Column(verbose_name='Slug')
|
slug = tables.Column(verbose_name='Slug')
|
||||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||||
verbose_name='')
|
verbose_name='')
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
|
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -437,7 +432,7 @@ class PlatformTable(BaseTable):
|
|||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -447,7 +442,7 @@ class PlatformTable(BaseTable):
|
|||||||
class DeviceTable(BaseTable):
|
class DeviceTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||||
@ -474,7 +469,7 @@ class DeviceDetailTable(DeviceTable):
|
|||||||
|
|
||||||
class DeviceImportTable(BaseTable):
|
class DeviceImportTable(BaseTable):
|
||||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||||
@ -587,3 +582,22 @@ class InventoryItemTable(BaseTable):
|
|||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
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
@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_FRONT,
|
'face': RACK_FACE_FRONT,
|
||||||
'position': 41,
|
'position': 41,
|
||||||
'platform': get_id(Platform, 'juniper-junos'),
|
'platform': get_id(Platform, 'juniper-junos'),
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_FRONT,
|
'face': RACK_FACE_FRONT,
|
||||||
'position': 1,
|
'position': 1,
|
||||||
'platform': get_id(Platform, 'juniper-junos'),
|
'platform': get_id(Platform, 'juniper-junos'),
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertFalse(test.is_valid())
|
self.assertFalse(test.is_valid())
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': None,
|
'face': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
'platform': None,
|
'platform': None,
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid())
|
self.assertTrue(test.is_valid())
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
|
|||||||
'face': RACK_FACE_REAR,
|
'face': RACK_FACE_REAR,
|
||||||
'position': None,
|
'position': None,
|
||||||
'platform': None,
|
'platform': None,
|
||||||
'status': STATUS_ACTIVE,
|
'status': DEVICE_STATUS_ACTIVE,
|
||||||
})
|
})
|
||||||
self.assertTrue(test.is_valid())
|
self.assertTrue(test.is_valid())
|
||||||
self.assertTrue(test.save())
|
self.assertTrue(test.save())
|
||||||
|
@ -140,8 +140,8 @@ urlpatterns = [
|
|||||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
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/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'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
|
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
|
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), 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+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||||
|
|
||||||
@ -150,17 +150,18 @@ 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/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/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'^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.consoleserverport_connect, name='consoleserverport_connect'),
|
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.consoleserverport_disconnect, name='consoleserverport_disconnect'),
|
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), 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+)/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/(?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
|
# Power ports
|
||||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
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/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'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
|
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
|
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), 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+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||||
|
|
||||||
@ -169,10 +170,11 @@ 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/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/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'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
|
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
|
||||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
|
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), 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+)/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/(?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
|
# Interfaces
|
||||||
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||||
@ -180,10 +182,11 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
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/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+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
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.interfaceconnection_delete, name='interfaceconnection_delete'),
|
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
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/(?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
|
# Device bays
|
||||||
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||||
@ -191,8 +194,9 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
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+)/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+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
|
||||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
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'),
|
||||||
|
|
||||||
# Inventory items
|
# Inventory items
|
||||||
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
|
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
|
||||||
@ -211,4 +215,12 @@ urlpatterns = [
|
|||||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
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'),
|
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
@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||||
|
|
||||||
from extras import filters
|
from extras import filters
|
||||||
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||||
from extras.reports import get_report, get_reports
|
from extras.reports import get_report, get_reports
|
||||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
|||||||
# Graphs
|
# Graphs
|
||||||
#
|
#
|
||||||
|
|
||||||
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
class GraphViewSet(ModelViewSet):
|
||||||
queryset = Graph.objects.all()
|
queryset = Graph.objects.all()
|
||||||
serializer_class = serializers.GraphSerializer
|
serializer_class = serializers.GraphSerializer
|
||||||
write_serializer_class = serializers.WritableGraphSerializer
|
write_serializer_class = serializers.WritableGraphSerializer
|
||||||
@ -75,7 +75,7 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# Export templates
|
# Export templates
|
||||||
#
|
#
|
||||||
|
|
||||||
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
class ExportTemplateViewSet(ModelViewSet):
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
serializer_class = serializers.ExportTemplateSerializer
|
serializer_class = serializers.ExportTemplateSerializer
|
||||||
filter_class = filters.ExportTemplateFilter
|
filter_class = filters.ExportTemplateFilter
|
||||||
@ -85,7 +85,7 @@ class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# Topology maps
|
# Topology maps
|
||||||
#
|
#
|
||||||
|
|
||||||
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
class TopologyMapViewSet(ModelViewSet):
|
||||||
queryset = TopologyMap.objects.select_related('site')
|
queryset = TopologyMap.objects.select_related('site')
|
||||||
serializer_class = serializers.TopologyMapSerializer
|
serializer_class = serializers.TopologyMapSerializer
|
||||||
write_serializer_class = serializers.WritableTopologyMapSerializer
|
write_serializer_class = serializers.WritableTopologyMapSerializer
|
||||||
@ -115,7 +115,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# Image attachments
|
# Image attachments
|
||||||
#
|
#
|
||||||
|
|
||||||
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
class ImageAttachmentViewSet(ModelViewSet):
|
||||||
queryset = ImageAttachment.objects.all()
|
queryset = ImageAttachment.objects.all()
|
||||||
serializer_class = serializers.ImageAttachmentSerializer
|
serializer_class = serializers.ImageAttachmentSerializer
|
||||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||||
|
@ -8,7 +8,7 @@ from django.db import transaction
|
|||||||
from ncclient.transport.errors import AuthenticationError
|
from ncclient.transport.errors import AuthenticationError
|
||||||
from paramiko import AuthenticationException
|
from paramiko import AuthenticationException
|
||||||
|
|
||||||
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
|
from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -41,7 +41,7 @@ class Command(BaseCommand):
|
|||||||
self.password = getpass("Password: ")
|
self.password = getpass("Password: ")
|
||||||
|
|
||||||
# Attempt to inventory only active devices
|
# Attempt to inventory only active devices
|
||||||
device_list = Device.objects.filter(status=STATUS_ACTIVE)
|
device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
|
||||||
|
|
||||||
# --site: Include only devices belonging to specified site(s)
|
# --site: Include only devices belonging to specified site(s)
|
||||||
if options['site']:
|
if options['site']:
|
||||||
|
@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:graph-list')
|
url = reverse('extras-api:graph-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Graph.objects.count(), 4)
|
self.assertEqual(Graph.objects.count(), 4)
|
||||||
@ -63,6 +63,35 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(graph4.name, data['name'])
|
self.assertEqual(graph4.name, data['name'])
|
||||||
self.assertEqual(graph4.source, data['source'])
|
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):
|
def test_update_graph(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -72,7 +101,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Graph.objects.count(), 3)
|
self.assertEqual(Graph.objects.count(), 3)
|
||||||
@ -135,7 +164,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:exporttemplate-list')
|
url = reverse('extras-api:exporttemplate-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||||
@ -144,6 +173,35 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(exporttemplate4.name, data['name'])
|
self.assertEqual(exporttemplate4.name, data['name'])
|
||||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
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):
|
def test_update_exporttemplate(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -153,7 +211,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(ExportTemplate.objects.count(), 3)
|
self.assertEqual(ExportTemplate.objects.count(), 3)
|
||||||
|
@ -3,9 +3,11 @@ from __future__ import unicode_literals
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
from rest_framework.validators import UniqueTogetherValidator
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
||||||
|
from dcim.models import Interface
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from ipam.constants import (
|
from ipam.constants import (
|
||||||
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
|
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
|
||||||
@ -25,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VRF
|
model = VRF
|
||||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NestedVRFSerializer(serializers.ModelSerializer):
|
class NestedVRFSerializer(serializers.ModelSerializer):
|
||||||
@ -40,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VRF
|
model = VRF
|
||||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -90,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NestedAggregateSerializer(serializers.ModelSerializer):
|
class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||||
@ -105,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -165,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer):
|
|||||||
model = VLAN
|
model = VLAN
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -181,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLAN
|
model = VLAN
|
||||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
|
fields = [
|
||||||
|
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
@ -215,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
|
|||||||
model = Prefix
|
model = Prefix
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -233,23 +245,47 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
|
|||||||
model = Prefix
|
model = Prefix
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# IP addresses
|
||||||
#
|
#
|
||||||
|
|
||||||
class IPAddressInterfaceSerializer(InterfaceSerializer):
|
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
|
||||||
|
device = NestedDeviceSerializer()
|
||||||
virtual_machine = NestedVirtualMachineSerializer()
|
virtual_machine = NestedVirtualMachineSerializer()
|
||||||
|
|
||||||
class Meta(InterfaceSerializer.Meta):
|
class Meta(InterfaceSerializer.Meta):
|
||||||
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
|
'id', 'url', 'device', 'virtual_machine', 'name',
|
||||||
'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):
|
class IPAddressSerializer(CustomFieldModelSerializer):
|
||||||
vrf = NestedVRFSerializer()
|
vrf = NestedVRFSerializer()
|
||||||
@ -262,7 +298,7 @@ class IPAddressSerializer(CustomFieldModelSerializer):
|
|||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||||
'nat_outside', 'custom_fields',
|
'nat_outside', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -284,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
|
|||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||||
'custom_fields',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -314,7 +350,10 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
fields = [
|
||||||
|
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||||
@ -322,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
fields = [
|
||||||
|
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||||
|
'last_updated',
|
||||||
|
]
|
||||||
|
@ -6,12 +6,11 @@ from rest_framework import status
|
|||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from ipam import filters
|
from ipam import filters
|
||||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
|||||||
# VRFs
|
# VRFs
|
||||||
#
|
#
|
||||||
|
|
||||||
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class VRFViewSet(CustomFieldModelViewSet):
|
||||||
queryset = VRF.objects.select_related('tenant')
|
queryset = VRF.objects.select_related('tenant')
|
||||||
serializer_class = serializers.VRFSerializer
|
serializer_class = serializers.VRFSerializer
|
||||||
write_serializer_class = serializers.WritableVRFSerializer
|
write_serializer_class = serializers.WritableVRFSerializer
|
||||||
@ -54,7 +53,7 @@ class RIRViewSet(ModelViewSet):
|
|||||||
# Aggregates
|
# Aggregates
|
||||||
#
|
#
|
||||||
|
|
||||||
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class AggregateViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Aggregate.objects.select_related('rir')
|
queryset = Aggregate.objects.select_related('rir')
|
||||||
serializer_class = serializers.AggregateSerializer
|
serializer_class = serializers.AggregateSerializer
|
||||||
write_serializer_class = serializers.WritableAggregateSerializer
|
write_serializer_class = serializers.WritableAggregateSerializer
|
||||||
@ -75,12 +74,72 @@ class RoleViewSet(ModelViewSet):
|
|||||||
# Prefixes
|
# Prefixes
|
||||||
#
|
#
|
||||||
|
|
||||||
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class PrefixViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||||
serializer_class = serializers.PrefixSerializer
|
serializer_class = serializers.PrefixSerializer
|
||||||
write_serializer_class = serializers.WritablePrefixSerializer
|
write_serializer_class = serializers.WritablePrefixSerializer
|
||||||
filter_class = filters.PrefixFilter
|
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'])
|
@detail_route(url_path='available-ips', methods=['get', 'post'])
|
||||||
def available_ips(self, request, pk=None):
|
def available_ips(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
@ -97,28 +156,39 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
if not request.user.has_perm('ipam.add_ipaddress'):
|
if not request.user.has_perm('ipam.add_ipaddress'):
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
# Find the first available IP address in the prefix
|
# Normalize to a list of objects
|
||||||
try:
|
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||||
ipaddress = list(prefix.get_available_ips())[0]
|
|
||||||
except IndexError:
|
# Determine if the requested number of IPs is available
|
||||||
|
available_ips = list(prefix.get_available_ips())
|
||||||
|
if len(available_ips) < len(requested_ips):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"detail": "There are no available IPs within this prefix ({})".format(prefix)
|
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
|
||||||
|
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
|
||||||
},
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the new IP address
|
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
|
||||||
data = request.data.copy()
|
for requested_ip in requested_ips:
|
||||||
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
|
requested_ip['address'] = available_ips.pop(0)
|
||||||
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||||
serializer = serializers.WritableIPAddressSerializer(data=data)
|
|
||||||
|
# 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)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Determine the maximum amount of IPs to return
|
# Determine the maximum number of IPs to return
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
||||||
@ -146,11 +216,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# IP addresses
|
# IP addresses
|
||||||
#
|
#
|
||||||
|
|
||||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||||
queryset = IPAddress.objects.select_related(
|
queryset = IPAddress.objects.select_related(
|
||||||
'vrf__tenant', 'tenant', 'nat_inside'
|
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'interface__device', 'interface__virtual_machine'
|
'nat_outside'
|
||||||
)
|
)
|
||||||
serializer_class = serializers.IPAddressSerializer
|
serializer_class = serializers.IPAddressSerializer
|
||||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||||
@ -161,7 +231,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# VLAN groups
|
# VLAN groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
class VLANGroupViewSet(ModelViewSet):
|
||||||
queryset = VLANGroup.objects.select_related('site')
|
queryset = VLANGroup.objects.select_related('site')
|
||||||
serializer_class = serializers.VLANGroupSerializer
|
serializer_class = serializers.VLANGroupSerializer
|
||||||
write_serializer_class = serializers.WritableVLANGroupSerializer
|
write_serializer_class = serializers.WritableVLANGroupSerializer
|
||||||
@ -172,7 +242,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
# VLANs
|
# VLANs
|
||||||
#
|
#
|
||||||
|
|
||||||
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class VLANViewSet(CustomFieldModelViewSet):
|
||||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||||
serializer_class = serializers.VLANSerializer
|
serializer_class = serializers.VLANSerializer
|
||||||
write_serializer_class = serializers.WritableVLANSerializer
|
write_serializer_class = serializers.WritableVLANSerializer
|
||||||
@ -183,7 +253,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Services
|
# Services
|
||||||
#
|
#
|
||||||
|
|
||||||
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
|
class ServiceViewSet(ModelViewSet):
|
||||||
queryset = Service.objects.select_related('device')
|
queryset = Service.objects.select_related('device')
|
||||||
serializer_class = serializers.ServiceSerializer
|
serializer_class = serializers.ServiceSerializer
|
||||||
write_serializer_class = serializers.WritableServiceSerializer
|
write_serializer_class = serializers.WritableServiceSerializer
|
||||||
|
@ -99,11 +99,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
method='search',
|
method='search',
|
||||||
label='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(
|
within = django_filters.CharFilter(
|
||||||
method='search_within',
|
method='search_within',
|
||||||
label='Within prefix',
|
label='Within prefix',
|
||||||
@ -262,16 +257,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device = django_filters.CharFilter(
|
||||||
name='interface__device',
|
method='filter_device',
|
||||||
queryset=Device.objects.all(),
|
name='name',
|
||||||
label='Device (ID)',
|
label='Device',
|
||||||
)
|
)
|
||||||
device = django_filters.ModelMultipleChoiceFilter(
|
device_id = django_filters.NumberFilter(
|
||||||
name='interface__device__name',
|
method='filter_device',
|
||||||
queryset=Device.objects.all(),
|
name='pk',
|
||||||
to_field_name='name',
|
label='Device (ID)',
|
||||||
label='Device (name)',
|
|
||||||
)
|
)
|
||||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='interface__virtual_machine',
|
name='interface__virtual_machine',
|
||||||
@ -324,6 +318,14 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(address__net_mask_length=value)
|
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):
|
class VLANGroupFilter(django_filters.FilterSet):
|
||||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
@ -931,8 +931,9 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||||
if self.instance.device:
|
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(
|
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||||
interface__device=self.instance.device
|
interface_id__in=vc_interface_ids
|
||||||
)
|
)
|
||||||
elif self.instance.virtual_machine:
|
elif self.instance.virtual_machine:
|
||||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||||
|
@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vrf-list')
|
url = reverse('ipam-api:vrf-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(VRF.objects.count(), 4)
|
self.assertEqual(VRF.objects.count(), 4)
|
||||||
@ -55,6 +55,32 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(vrf4.name, data['name'])
|
self.assertEqual(vrf4.name, data['name'])
|
||||||
self.assertEqual(vrf4.rd, data['rd'])
|
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):
|
def test_update_vrf(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -63,7 +89,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(VRF.objects.count(), 3)
|
self.assertEqual(VRF.objects.count(), 3)
|
||||||
@ -114,7 +140,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:rir-list')
|
url = reverse('ipam-api:rir-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(RIR.objects.count(), 4)
|
self.assertEqual(RIR.objects.count(), 4)
|
||||||
@ -122,6 +148,32 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(rir4.name, data['name'])
|
self.assertEqual(rir4.name, data['name'])
|
||||||
self.assertEqual(rir4.slug, data['slug'])
|
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):
|
def test_update_rir(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -130,7 +182,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(RIR.objects.count(), 3)
|
self.assertEqual(RIR.objects.count(), 3)
|
||||||
@ -183,7 +235,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:aggregate-list')
|
url = reverse('ipam-api:aggregate-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Aggregate.objects.count(), 4)
|
self.assertEqual(Aggregate.objects.count(), 4)
|
||||||
@ -191,6 +243,32 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(str(aggregate4.prefix), data['prefix'])
|
self.assertEqual(str(aggregate4.prefix), data['prefix'])
|
||||||
self.assertEqual(aggregate4.rir_id, data['rir'])
|
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):
|
def test_update_aggregate(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -199,7 +277,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Aggregate.objects.count(), 3)
|
self.assertEqual(Aggregate.objects.count(), 3)
|
||||||
@ -250,7 +328,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:role-list')
|
url = reverse('ipam-api:role-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Role.objects.count(), 4)
|
self.assertEqual(Role.objects.count(), 4)
|
||||||
@ -258,6 +336,32 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(role4.name, data['name'])
|
self.assertEqual(role4.name, data['name'])
|
||||||
self.assertEqual(role4.slug, data['slug'])
|
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):
|
def test_update_role(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -266,7 +370,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Role.objects.count(), 3)
|
self.assertEqual(Role.objects.count(), 3)
|
||||||
@ -324,7 +428,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:prefix-list')
|
url = reverse('ipam-api:prefix-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Prefix.objects.count(), 4)
|
self.assertEqual(Prefix.objects.count(), 4)
|
||||||
@ -335,6 +439,29 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(prefix4.vlan_id, data['vlan'])
|
self.assertEqual(prefix4.vlan_id, data['vlan'])
|
||||||
self.assertEqual(prefix4.role_id, data['role'])
|
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):
|
def test_update_prefix(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -346,7 +473,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Prefix.objects.count(), 3)
|
self.assertEqual(Prefix.objects.count(), 3)
|
||||||
@ -365,7 +492,73 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||||
self.assertEqual(Prefix.objects.count(), 2)
|
self.assertEqual(Prefix.objects.count(), 2)
|
||||||
|
|
||||||
def test_available_ips(self):
|
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):
|
||||||
|
|
||||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
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})
|
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||||
@ -380,12 +573,17 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
response = self.client.get(url, **self.header)
|
response = self.client.get(url, **self.header)
|
||||||
self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
|
self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
|
||||||
|
|
||||||
# Create all six available IPs
|
def test_create_single_available_ip(self):
|
||||||
for i in range(6):
|
|
||||||
|
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):
|
||||||
data = {
|
data = {
|
||||||
'description': 'Test IP {}'.format(i)
|
'description': 'Test IP {}'.format(i)
|
||||||
}
|
}
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(response.data['description'], data['description'])
|
self.assertEqual(response.data['description'], data['description'])
|
||||||
|
|
||||||
@ -394,6 +592,27 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('detail', response.data)
|
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):
|
class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||||
|
|
||||||
@ -430,7 +649,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:ipaddress-list')
|
url = reverse('ipam-api:ipaddress-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(IPAddress.objects.count(), 4)
|
self.assertEqual(IPAddress.objects.count(), 4)
|
||||||
@ -438,6 +657,29 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(str(ipaddress4.address), data['address'])
|
self.assertEqual(str(ipaddress4.address), data['address'])
|
||||||
self.assertEqual(ipaddress4.vrf_id, data['vrf'])
|
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):
|
def test_update_ipaddress(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -446,7 +688,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(IPAddress.objects.count(), 3)
|
self.assertEqual(IPAddress.objects.count(), 3)
|
||||||
@ -497,7 +739,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vlangroup-list')
|
url = reverse('ipam-api:vlangroup-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(VLANGroup.objects.count(), 4)
|
self.assertEqual(VLANGroup.objects.count(), 4)
|
||||||
@ -505,6 +747,32 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(vlangroup4.name, data['name'])
|
self.assertEqual(vlangroup4.name, data['name'])
|
||||||
self.assertEqual(vlangroup4.slug, data['slug'])
|
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):
|
def test_update_vlangroup(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -513,7 +781,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(VLANGroup.objects.count(), 3)
|
self.assertEqual(VLANGroup.objects.count(), 3)
|
||||||
@ -564,7 +832,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vlan-list')
|
url = reverse('ipam-api:vlan-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(VLAN.objects.count(), 4)
|
self.assertEqual(VLAN.objects.count(), 4)
|
||||||
@ -572,6 +840,32 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(vlan4.vid, data['vid'])
|
self.assertEqual(vlan4.vid, data['vid'])
|
||||||
self.assertEqual(vlan4.name, data['name'])
|
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):
|
def test_update_vlan(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -580,7 +874,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(VLAN.objects.count(), 3)
|
self.assertEqual(VLAN.objects.count(), 3)
|
||||||
@ -649,7 +943,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:service-list')
|
url = reverse('ipam-api:service-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Service.objects.count(), 4)
|
self.assertEqual(Service.objects.count(), 4)
|
||||||
@ -659,6 +953,38 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(service4.protocol, data['protocol'])
|
self.assertEqual(service4.protocol, data['protocol'])
|
||||||
self.assertEqual(service4.port, data['port'])
|
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):
|
def test_update_service(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -669,7 +995,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Service.objects.count(), 3)
|
self.assertEqual(Service.objects.count(), 3)
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
from django.contrib.messages import constants as messages
|
from django.contrib.messages import constants as messages
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
@ -12,8 +14,15 @@ except ImportError:
|
|||||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
"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.2.11-dev'
|
VERSION = '2.3.0-dev'
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
@ -125,6 +134,7 @@ INSTALLED_APPS = (
|
|||||||
'mptt',
|
'mptt',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework_swagger',
|
'rest_framework_swagger',
|
||||||
|
'timezone_field',
|
||||||
'circuits',
|
'circuits',
|
||||||
'dcim',
|
'dcim',
|
||||||
'ipam',
|
'ipam',
|
||||||
|
@ -121,7 +121,7 @@ input[name="pk"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
|
th.pk, td.pk {
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
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
|
|
@ -1,14 +1,24 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|
||||||
// "Toggle all" checkbox (table header)
|
// "Toggle" checkbox for object lists (PK column)
|
||||||
$('#toggle_all').click(function() {
|
$('input:checkbox.toggle').click(function() {
|
||||||
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||||
|
|
||||||
|
// Show the "select all" box if present
|
||||||
if ($(this).is(':checked')) {
|
if ($(this).is(':checked')) {
|
||||||
$('#select_all_box').removeClass('hidden');
|
$('#select_all_box').removeClass('hidden');
|
||||||
} else {
|
} else {
|
||||||
$('#select_all').prop('checked', false);
|
$('#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
|
// Enable hidden buttons when "select all" is checked
|
||||||
$('#select_all').click(function() {
|
$('#select_all').click(function() {
|
||||||
if ($(this).is(':checked')) {
|
if ($(this).is(':checked')) {
|
||||||
@ -17,21 +27,6 @@ $(document).ready(function() {
|
|||||||
$('#select_all_box').find('button').prop('disabled', 'disabled');
|
$('#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
|
// Slugify
|
||||||
function slugify(s, num_chars) {
|
function slugify(s, num_chars) {
|
||||||
@ -71,59 +66,65 @@ $(document).ready(function() {
|
|||||||
$('select[filter-for]').change(function() {
|
$('select[filter-for]').change(function() {
|
||||||
|
|
||||||
// Resolve child field by ID specified in parent
|
// Resolve child field by ID specified in parent
|
||||||
var child_name = $(this).attr('filter-for');
|
var child_names = $(this).attr('filter-for');
|
||||||
var child_field = $('#id_' + child_name);
|
var parent = this;
|
||||||
var child_selected = child_field.val();
|
|
||||||
|
|
||||||
// Wipe out any existing options within the child field and create a default option
|
// allow more than one child
|
||||||
child_field.empty();
|
$.each(child_names.split(" "), function(_, child_name){
|
||||||
if (!child_field.attr('multiple')) {
|
|
||||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($(this).val() || $(this).attr('nullable') == 'true') {
|
var child_field = $('#id_' + child_name);
|
||||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
var child_selected = child_field.val();
|
||||||
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
|
// Wipe out any existing options within the child field and create a default option
|
||||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
child_field.empty();
|
||||||
var match;
|
if (!child_field.attr('multiple')) {
|
||||||
var rendered_url = api_url;
|
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||||
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 ($(parent).val() || $(parent).attr('nullable') == 'true') {
|
||||||
if (rendered_url.search('{{') < 0) {
|
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||||
console.log(child_name + ": Fetching " + rendered_url);
|
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||||
$.ajax({
|
var initial_value = child_field.attr('initial');
|
||||||
url: rendered_url,
|
var display_field = child_field.attr('display-field') || 'name';
|
||||||
dataType: 'json',
|
|
||||||
success: function(response, status) {
|
// Determine the filter fields needed to make an API call
|
||||||
$.each(response.results, function(index, choice) {
|
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
var match;
|
||||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
var rendered_url = api_url;
|
||||||
option.attr("disabled", "disabled");
|
while (match = filter_regex.exec(api_url)) {
|
||||||
} else if (choice.id == child_selected) {
|
var filter_field = $('#id_' + match[1]);
|
||||||
option.attr("selected", "selected");
|
if (filter_field.val()) {
|
||||||
}
|
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||||
child_field.append(option);
|
} 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
// 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();
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -45,13 +45,20 @@ class WritableSecretSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Secret
|
model = Secret
|
||||||
fields = ['id', 'device', 'role', 'name', 'plaintext']
|
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
def validate(self, data):
|
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.
|
# Validate uniqueness of name if one has been provided.
|
||||||
if data.get('name', None):
|
if data.get('name'):
|
||||||
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
|
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
|
||||||
validator.set_context(self)
|
validator.set_context(self)
|
||||||
validator(data)
|
validator(data)
|
||||||
|
@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest
|
|||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
|
|
||||||
from secrets import filters
|
from secrets import filters
|
||||||
from secrets.exceptions import InvalidKey
|
from secrets.exceptions import InvalidKey
|
||||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||||
@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet):
|
|||||||
# Secrets
|
# Secrets
|
||||||
#
|
#
|
||||||
|
|
||||||
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
class SecretViewSet(ModelViewSet):
|
||||||
queryset = Secret.objects.select_related(
|
queryset = Secret.objects.select_related(
|
||||||
'device__primary_ip4', 'device__primary_ip6', 'role',
|
'device__primary_ip4', 'device__primary_ip6', 'role',
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
@ -56,17 +56,13 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
|
|
||||||
master_key = None
|
master_key = None
|
||||||
|
|
||||||
def _get_encrypted_fields(self, serializer):
|
def get_serializer_context(self):
|
||||||
"""
|
|
||||||
Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
|
# Make the master key available to the serializer for encrypting plaintext values
|
||||||
ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
|
context = super(SecretViewSet, self).get_serializer_context()
|
||||||
"""
|
context['master_key'] = self.master_key
|
||||||
s = Secret(plaintext=serializer.validated_data['plaintext'])
|
|
||||||
s.encrypt(self.master_key)
|
return context
|
||||||
return ({
|
|
||||||
'ciphertext': s.ciphertext,
|
|
||||||
'hash': s.hash,
|
|
||||||
})
|
|
||||||
|
|
||||||
def initial(self, request, *args, **kwargs):
|
def initial(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -128,12 +124,6 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
|||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
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):
|
class GetSessionKeyViewSet(ViewSet):
|
||||||
"""
|
"""
|
||||||
|
@ -81,12 +81,12 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
|||||||
def test_create_secretrole(self):
|
def test_create_secretrole(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'name': 'Test SecretRole 4',
|
'name': 'Test Secret Role 4',
|
||||||
'slug': 'test-secretrole-4',
|
'slug': 'test-secret-role-4',
|
||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('secrets-api:secretrole-list')
|
url = reverse('secrets-api:secretrole-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(SecretRole.objects.count(), 4)
|
self.assertEqual(SecretRole.objects.count(), 4)
|
||||||
@ -94,6 +94,32 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(secretrole4.name, data['name'])
|
self.assertEqual(secretrole4.name, data['name'])
|
||||||
self.assertEqual(secretrole4.slug, data['slug'])
|
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):
|
def test_update_secretrole(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -102,7 +128,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(SecretRole.objects.count(), 3)
|
self.assertEqual(SecretRole.objects.count(), 3)
|
||||||
@ -138,9 +164,9 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.plaintext = {
|
self.plaintext = {
|
||||||
'secret1': 'Secret#1Plaintext',
|
'secret1': 'Secret #1 Plaintext',
|
||||||
'secret2': 'Secret#2Plaintext',
|
'secret2': 'Secret #2 Plaintext',
|
||||||
'secret3': 'Secret#3Plaintext',
|
'secret3': 'Secret #3 Plaintext',
|
||||||
}
|
}
|
||||||
|
|
||||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||||
@ -187,11 +213,12 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
|||||||
data = {
|
data = {
|
||||||
'device': self.device.pk,
|
'device': self.device.pk,
|
||||||
'role': self.secretrole1.pk,
|
'role': self.secretrole1.pk,
|
||||||
'plaintext': 'Secret#4Plaintext',
|
'name': 'Test Secret 4',
|
||||||
|
'plaintext': 'Secret #4 Plaintext',
|
||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('secrets-api:secret-list')
|
url = reverse('secrets-api:secret-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||||
@ -201,6 +228,38 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(secret4.role_id, data['role'])
|
self.assertEqual(secret4.role_id, data['role'])
|
||||||
self.assertEqual(secret4.plaintext, data['plaintext'])
|
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):
|
def test_update_secret(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -210,7 +269,7 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||||
|
@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
|
<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
|
||||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.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 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
|
||||||
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
|
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
|
||||||
|
@ -46,6 +46,12 @@
|
|||||||
<strong>Circuit</strong>
|
<strong>Circuit</strong>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover panel-body attr-table">
|
<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>
|
<tr>
|
||||||
<td>Provider</td>
|
<td>Provider</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
{% render_field form.provider %}
|
{% render_field form.provider %}
|
||||||
{% render_field form.cid %}
|
{% render_field form.cid %}
|
||||||
{% render_field form.type %}
|
{% render_field form.type %}
|
||||||
|
{% render_field form.status %}
|
||||||
{% render_field form.install_date %}
|
{% render_field form.install_date %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>
|
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>
|
||||||
|
55
netbox/templates/dcim/bulk_rename.html
Normal file
55
netbox/templates/dcim/bulk_rename.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% 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 %}
|
@ -98,6 +98,46 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Management</strong>
|
<strong>Management</strong>
|
||||||
@ -339,45 +379,48 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Device Bays</strong>
|
<strong>Device Bays</strong>
|
||||||
<div class="pull-right">
|
</div>
|
||||||
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
|
<table class="table table-hover table-headings panel-body component-list">
|
||||||
<button class="btn btn-default btn-xs toggle">
|
<thead>
|
||||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
<tr>
|
||||||
</button>
|
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
|
||||||
{% endif %}
|
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||||
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
|
{% 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">— No device bays defined —</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">
|
||||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
<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
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="clearfix"></div>
|
||||||
</div>
|
{% endif %}
|
||||||
<table class="table table-hover panel-body component-list">
|
</div>
|
||||||
{% 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>
|
</div>
|
||||||
{% if perms.dcim.delete_devicebay %}
|
{% if perms.dcim.delete_devicebay %}
|
||||||
</form>
|
</form>
|
||||||
@ -396,66 +439,61 @@
|
|||||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||||
</button>
|
</button>
|
||||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
</div>
|
||||||
<button class="btn btn-default btn-xs toggle">
|
</div>
|
||||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||||
</button>
|
<thead>
|
||||||
{% endif %}
|
<tr>
|
||||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
{% 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">— No interfaces defined —</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">
|
||||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
<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
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="clearfix"></div>
|
||||||
</div>
|
{% endif %}
|
||||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
{% if perms.dcim.delete_interface %}
|
{% if perms.dcim.delete_interface %}
|
||||||
</form>
|
</form>
|
||||||
@ -469,58 +507,51 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Console Server Ports</strong>
|
<strong>Console Server Ports</strong>
|
||||||
<div class="pull-right">
|
</div>
|
||||||
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
|
<table class="table table-hover table-headings panel-body component-list">
|
||||||
<button class="btn btn-default btn-xs toggle">
|
<thead>
|
||||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
<tr>
|
||||||
</button>
|
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||||
{% endif %}
|
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||||
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
|
{% 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">— No console server ports defined —</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">
|
||||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
<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
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="clearfix"></div>
|
||||||
|
{% 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>
|
</div>
|
||||||
{% if perms.dcim.delete_consoleserverport %}
|
{% if perms.dcim.delete_consoleserverport %}
|
||||||
</form>
|
</form>
|
||||||
@ -534,58 +565,51 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Power Outlets</strong>
|
<strong>Power Outlets</strong>
|
||||||
<div class="pull-right">
|
</div>
|
||||||
{% if perms.dcim.change_poweroutlet and power_outlets|length > 1 %}
|
<table class="table table-hover table-headings panel-body component-list">
|
||||||
<button class="btn btn-default btn-xs toggle">
|
<thead>
|
||||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
<tr>
|
||||||
</button>
|
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||||
{% endif %}
|
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||||
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
|
{% 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">— No power outlets defined —</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">
|
||||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
<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
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
<div class="clearfix"></div>
|
||||||
|
{% 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>
|
</div>
|
||||||
{% if perms.dcim.delete_poweroutlet %}
|
{% if perms.dcim.delete_poweroutlet %}
|
||||||
</form>
|
</form>
|
||||||
|
@ -16,4 +16,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay }}
|
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
|
||||||
</td>
|
</td>
|
||||||
{% if devicebay.installed_device %}
|
{% if devicebay.installed_device %}
|
||||||
<td>
|
<td>
|
||||||
@ -19,7 +19,7 @@
|
|||||||
<span class="text-muted">Vacant</span>
|
<span class="text-muted">Vacant</span>
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td colspan="2" class="text-right">
|
<td class="text-right">
|
||||||
{% if perms.dcim.change_devicebay %}
|
{% if perms.dcim.change_devicebay %}
|
||||||
{% if devicebay.installed_device %}
|
{% if devicebay.installed_device %}
|
||||||
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||||
|
@ -4,19 +4,6 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>{{ title }}</strong>
|
<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>
|
</div>
|
||||||
{% include 'responsive_table.html' %}
|
{% include 'responsive_table.html' %}
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
|
@ -98,18 +98,18 @@
|
|||||||
<i class="fa fa-plug" aria-hidden="true"></i>
|
<i class="fa fa-plug" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
|
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Disconnect">
|
||||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
|
{% 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">
|
<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>
|
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
|
<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">
|
||||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
|
<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">
|
||||||
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
28
netbox/templates/dcim/interface_edit.html
Normal file
28
netbox/templates/dcim/interface_edit.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% 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 %}
|
@ -225,12 +225,20 @@
|
|||||||
<table class="table table-hover panel-body">
|
<table class="table table-hover panel-body">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Units</th>
|
<th>Units</th>
|
||||||
|
<th>Tenant</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for resv in reservations %}
|
{% for resv in reservations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ resv.unit_list }}</td>
|
<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>
|
<td>
|
||||||
{{ resv.description }}<br />
|
{{ resv.description }}<br />
|
||||||
<small>{{ resv.user }} · {{ resv.created }}</small>
|
<small>{{ resv.user }} · {{ resv.created }}</small>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{% extends '_base.html' %}
|
{% extends '_base.html' %}
|
||||||
{% load static from staticfiles %}
|
{% load static from staticfiles %}
|
||||||
|
{% load tz %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -57,6 +58,12 @@
|
|||||||
<strong>Site</strong>
|
<strong>Site</strong>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover panel-body attr-table">
|
<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>
|
<tr>
|
||||||
<td>Region</td>
|
<td>Region</td>
|
||||||
<td>
|
<td>
|
||||||
@ -105,6 +112,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
@ -7,9 +7,11 @@
|
|||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
{% render_field form.name %}
|
{% render_field form.name %}
|
||||||
{% render_field form.slug %}
|
{% render_field form.slug %}
|
||||||
|
{% render_field form.status %}
|
||||||
{% render_field form.region %}
|
{% render_field form.region %}
|
||||||
{% render_field form.facility %}
|
{% render_field form.facility %}
|
||||||
{% render_field form.asn %}
|
{% render_field form.asn %}
|
||||||
|
{% render_field form.time_zone %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
35
netbox/templates/dcim/virtualchassis_add_member.html
Normal file
35
netbox/templates/dcim/virtualchassis_add_member.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% 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 %}
|
103
netbox/templates/dcim/virtualchassis_edit.html
Normal file
103
netbox/templates/dcim/virtualchassis_edit.html
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
{% 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-8 col-md-offset-2">
|
||||||
|
<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>ID</th>
|
||||||
|
<th>Rack/Unit</th>
|
||||||
|
<th>Serial</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>{{ device.pk }}</td>
|
||||||
|
<td>
|
||||||
|
{% if device.rack %}
|
||||||
|
{{ device.rack }} / {{ device.position }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if device.serial %}
|
||||||
|
{{ device.serial }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ form.vc_position }}
|
||||||
|
{% if form.vc_position.errors %}
|
||||||
|
<br /><small class="text-danger">{{ form.vc_position.errors.0 }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ form.vc_priority }}
|
||||||
|
{% if form.vc_priority.errors %}
|
||||||
|
<br /><small class="text-danger">{{ form.vc_priority.errors.0 }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</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-8 col-md-offset-2 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 %}
|
14
netbox/templates/dcim/virtualchassis_list.html
Normal file
14
netbox/templates/dcim/virtualchassis_list.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{% extends '_base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% include 'utilities/obj_table.html' %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
{% include 'inc/search_panel.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
8
netbox/templates/dcim/virtualchassis_remove_member.html
Normal file
8
netbox/templates/dcim/virtualchassis_remove_member.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{% 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 %}
|
@ -104,7 +104,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
|
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li class="dropdown-header">Devices</li>
|
<li class="dropdown-header">Devices</li>
|
||||||
@ -135,6 +135,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
|
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
|
||||||
|
</li>
|
||||||
<li class="divider"></li>
|
<li class="divider"></li>
|
||||||
<li class="dropdown-header">Device Types</li>
|
<li class="dropdown-header">Device Types</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -100,6 +100,10 @@
|
|||||||
<h2><a href="{% url 'dcim:rack_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
|
<h2><a href="{% url 'dcim:rack_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
|
||||||
<p>Racks</p>
|
<p>Racks</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 text-center">
|
||||||
|
<h2><a href="{% url 'dcim:rackreservation_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rackreservation_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rackreservation_count }}</a></h2>
|
||||||
|
<p>Rack reservations</p>
|
||||||
|
</div>
|
||||||
<div class="col-md-4 text-center">
|
<div class="col-md-4 text-center">
|
||||||
<h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
<h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
||||||
<p>Devices</p>
|
<p>Devices</p>
|
||||||
|
@ -235,42 +235,39 @@
|
|||||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||||
</button>
|
</button>
|
||||||
{% 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 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
|
|
||||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||||
<tr class="table-headings">
|
<thead>
|
||||||
{% 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' with device=vm %}
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6">No interfaces defined</td>
|
{% 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>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for iface in interfaces %}
|
||||||
|
{% include 'dcim/inc/interface.html' with device=vm %}
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center text-muted">— No interfaces defined —</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
{% if interfaces and perms.dcim.change_interface %}
|
{% if interfaces and perms.dcim.change_interface %}
|
||||||
|
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ vm.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 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
|
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
|
||||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||||
</button>
|
</button>
|
||||||
|
@ -35,7 +35,7 @@ class TenantSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tenant
|
model = Tenant
|
||||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
|
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||||
|
|
||||||
|
|
||||||
class NestedTenantSerializer(serializers.ModelSerializer):
|
class NestedTenantSerializer(serializers.ModelSerializer):
|
||||||
@ -50,4 +50,4 @@ class WritableTenantSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tenant
|
model = Tenant
|
||||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
|
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from tenancy import filters
|
from tenancy import filters
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@ -31,7 +29,7 @@ class TenantGroupViewSet(ModelViewSet):
|
|||||||
# Tenants
|
# Tenants
|
||||||
#
|
#
|
||||||
|
|
||||||
class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class TenantViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Tenant.objects.select_related('group')
|
queryset = Tenant.objects.select_related('group')
|
||||||
serializer_class = serializers.TenantSerializer
|
serializer_class = serializers.TenantSerializer
|
||||||
write_serializer_class = serializers.WritableTenantSerializer
|
write_serializer_class = serializers.WritableTenantSerializer
|
||||||
|
@ -44,7 +44,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('tenancy-api:tenantgroup-list')
|
url = reverse('tenancy-api:tenantgroup-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(TenantGroup.objects.count(), 4)
|
self.assertEqual(TenantGroup.objects.count(), 4)
|
||||||
@ -52,6 +52,32 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(tenantgroup4.name, data['name'])
|
self.assertEqual(tenantgroup4.name, data['name'])
|
||||||
self.assertEqual(tenantgroup4.slug, data['slug'])
|
self.assertEqual(tenantgroup4.slug, data['slug'])
|
||||||
|
|
||||||
|
def test_create_tenantgroup_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant Group 4',
|
||||||
|
'slug': 'test-tenant-group-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant Group 5',
|
||||||
|
'slug': 'test-tenant-group-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant Group 6',
|
||||||
|
'slug': 'test-tenant-group-6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('tenancy-api:tenantgroup-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(TenantGroup.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_tenantgroup(self):
|
def test_update_tenantgroup(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -60,7 +86,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
|
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(TenantGroup.objects.count(), 3)
|
self.assertEqual(TenantGroup.objects.count(), 3)
|
||||||
@ -114,7 +140,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('tenancy-api:tenant-list')
|
url = reverse('tenancy-api:tenant-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Tenant.objects.count(), 4)
|
self.assertEqual(Tenant.objects.count(), 4)
|
||||||
@ -123,6 +149,32 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(tenant4.slug, data['slug'])
|
self.assertEqual(tenant4.slug, data['slug'])
|
||||||
self.assertEqual(tenant4.group_id, data['group'])
|
self.assertEqual(tenant4.group_id, data['group'])
|
||||||
|
|
||||||
|
def test_create_tenant_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant 4',
|
||||||
|
'slug': 'test-tenant-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant 5',
|
||||||
|
'slug': 'test-tenant-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Tenant 6',
|
||||||
|
'slug': 'test-tenant-6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('tenancy-api:tenant-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Tenant.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_tenant(self):
|
def test_update_tenant(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -132,7 +184,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
|
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Tenant.objects.count(), 3)
|
self.assertEqual(Tenant.objects.count(), 3)
|
||||||
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
|||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from circuits.models import Circuit
|
from circuits.models import Circuit
|
||||||
from dcim.models import Site, Rack, Device
|
from dcim.models import Site, Rack, Device, RackReservation
|
||||||
from ipam.models import IPAddress, Prefix, VLAN, VRF
|
from ipam.models import IPAddress, Prefix, VLAN, VRF
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
@ -75,6 +75,7 @@ class TenantView(View):
|
|||||||
stats = {
|
stats = {
|
||||||
'site_count': Site.objects.filter(tenant=tenant).count(),
|
'site_count': Site.objects.filter(tenant=tenant).count(),
|
||||||
'rack_count': Rack.objects.filter(tenant=tenant).count(),
|
'rack_count': Rack.objects.filter(tenant=tenant).count(),
|
||||||
|
'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
|
||||||
'device_count': Device.objects.filter(tenant=tenant).count(),
|
'device_count': Device.objects.filter(tenant=tenant).count(),
|
||||||
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
|
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
|
||||||
'prefix_count': Prefix.objects.filter(
|
'prefix_count': Prefix.objects.filter(
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
import pytz
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from rest_framework import mixins
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import Field, ModelSerializer, ValidationError
|
from rest_framework.serializers import Field, ModelSerializer, ValidationError
|
||||||
from rest_framework.viewsets import ViewSet
|
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||||
|
|
||||||
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
|
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
|
||||||
|
|
||||||
@ -96,10 +98,51 @@ class ContentTypeFieldSerializer(Field):
|
|||||||
raise ValidationError("Invalid content type")
|
raise ValidationError("Invalid content type")
|
||||||
|
|
||||||
|
|
||||||
|
class TimeZoneField(Field):
|
||||||
|
"""
|
||||||
|
Represent a pytz time zone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def to_representation(self, obj):
|
||||||
|
return obj.zone if obj else None
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
if not data:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return pytz.timezone(str(data))
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
raise ValidationError('Invalid time zone "{}"'.format(data))
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Views
|
# Viewsets
|
||||||
#
|
#
|
||||||
|
|
||||||
|
class ModelViewSet(mixins.CreateModelMixin,
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
GenericViewSet):
|
||||||
|
"""
|
||||||
|
Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
|
||||||
|
1. Use an alternate serializer (if provided) for write operations
|
||||||
|
2. Accept either a single object or a list of objects to create
|
||||||
|
"""
|
||||||
|
def get_serializer_class(self):
|
||||||
|
# Check for a different serializer to use for write operations
|
||||||
|
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
|
||||||
|
return self.write_serializer_class
|
||||||
|
return self.serializer_class
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
# If a list of objects has been provided, initialize the serializer with many=True
|
||||||
|
if isinstance(kwargs.get('data', {}), list):
|
||||||
|
kwargs['many'] = True
|
||||||
|
return super(ModelViewSet, self).get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class FieldChoicesViewSet(ViewSet):
|
class FieldChoicesViewSet(ViewSet):
|
||||||
"""
|
"""
|
||||||
Expose the built-in numeric values which represent static choices for a model's field.
|
Expose the built-in numeric values which represent static choices for a model's field.
|
||||||
@ -135,25 +178,9 @@ class FieldChoicesViewSet(ViewSet):
|
|||||||
return Response(self._fields)
|
return Response(self._fields)
|
||||||
|
|
||||||
def retrieve(self, request, pk):
|
def retrieve(self, request, pk):
|
||||||
|
|
||||||
if pk not in self._fields:
|
if pk not in self._fields:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return Response(self._fields[pk])
|
return Response(self._fields[pk])
|
||||||
|
|
||||||
def get_view_name(self):
|
def get_view_name(self):
|
||||||
return "Field Choices"
|
return "Field Choices"
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Mixins
|
|
||||||
#
|
|
||||||
|
|
||||||
class WritableSerializerMixin(object):
|
|
||||||
"""
|
|
||||||
Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
|
|
||||||
"""
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
|
|
||||||
return self.write_serializer_class
|
|
||||||
return self.serializer_class
|
|
||||||
|
7
netbox/utilities/constants.py
Normal file
7
netbox/utilities/constants.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from utilities.forms import ChainedModelMultipleChoiceField
|
||||||
|
|
||||||
|
|
||||||
|
# Fields which are used on ManyToMany relationships
|
||||||
|
M2M_FIELD_TYPES = [
|
||||||
|
ChainedModelMultipleChoiceField,
|
||||||
|
]
|
@ -119,7 +119,7 @@ class ColorSelect(forms.Select):
|
|||||||
"""
|
"""
|
||||||
Extends the built-in Select widget to colorize each <option>.
|
Extends the built-in Select widget to colorize each <option>.
|
||||||
"""
|
"""
|
||||||
option_template_name = 'colorselect_option.html'
|
option_template_name = 'widgets/colorselect_option.html'
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs['choices'] = COLOR_CHOICES
|
kwargs['choices'] = COLOR_CHOICES
|
||||||
@ -144,7 +144,14 @@ class SelectWithDisabled(forms.Select):
|
|||||||
Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
|
Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
|
||||||
'label' (string) and 'disabled' (boolean).
|
'label' (string) and 'disabled' (boolean).
|
||||||
"""
|
"""
|
||||||
option_template_name = 'selectwithdisabled_option.html'
|
option_template_name = 'widgets/selectwithdisabled_option.html'
|
||||||
|
|
||||||
|
|
||||||
|
class SelectWithPK(forms.Select):
|
||||||
|
"""
|
||||||
|
Include the primary key of each option in the option label (e.g. "Router7 (4721)").
|
||||||
|
"""
|
||||||
|
option_template_name = 'widgets/select_option_with_pk.html'
|
||||||
|
|
||||||
|
|
||||||
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
||||||
|
@ -30,4 +30,4 @@ class ToggleColumn(tables.CheckBoxColumn):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def header(self):
|
def header(self):
|
||||||
return mark_safe('<input type="checkbox" id="toggle_all" title="Toggle all" />')
|
return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}{% if widget.value %} ({{ widget.value }}){% endif %}</option>
|
@ -1,5 +1,8 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
@ -117,6 +120,14 @@ def example_choices(field, arg=3):
|
|||||||
return ', '.join(examples) or 'None'
|
return ', '.join(examples) or 'None'
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter()
|
||||||
|
def tzoffset(value):
|
||||||
|
"""
|
||||||
|
Returns the hour offset of a given time zone using the current time.
|
||||||
|
"""
|
||||||
|
return datetime.datetime.now(value).strftime('%z')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
|
@ -9,9 +9,9 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
from django.db.models import ProtectedError
|
from django.db.models import ProtectedError
|
||||||
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
|
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.http import is_safe_url
|
from django.utils.http import is_safe_url
|
||||||
@ -22,6 +22,7 @@ from django_tables2 import RequestConfig
|
|||||||
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
|
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
|
||||||
from utilities.utils import queryset_to_csv
|
from utilities.utils import queryset_to_csv
|
||||||
from utilities.forms import BootstrapMixin, CSVDataField
|
from utilities.forms import BootstrapMixin, CSVDataField
|
||||||
|
from .constants import M2M_FIELD_TYPES
|
||||||
from .error_handlers import handle_protectederror
|
from .error_handlers import handle_protectederror
|
||||||
from .forms import ConfirmationForm
|
from .forms import ConfirmationForm
|
||||||
from .paginator import EnhancedPaginator
|
from .paginator import EnhancedPaginator
|
||||||
@ -510,31 +511,55 @@ class BulkEditView(View):
|
|||||||
|
|
||||||
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
||||||
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
|
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
|
||||||
|
|
||||||
# Update standard fields. If a field is listed in _nullify, delete its value.
|
|
||||||
nullified_fields = request.POST.getlist('_nullify')
|
nullified_fields = request.POST.getlist('_nullify')
|
||||||
fields_to_update = {}
|
|
||||||
for field in standard_fields:
|
|
||||||
if field in form.nullable_fields and field in nullified_fields:
|
|
||||||
if isinstance(form.fields[field], CharField):
|
|
||||||
fields_to_update[field] = ''
|
|
||||||
else:
|
|
||||||
fields_to_update[field] = None
|
|
||||||
elif form.cleaned_data[field] not in (None, ''):
|
|
||||||
fields_to_update[field] = form.cleaned_data[field]
|
|
||||||
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
|
||||||
|
|
||||||
# Update custom fields for objects
|
try:
|
||||||
if custom_fields:
|
|
||||||
objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
|
|
||||||
if objs_updated and not updated_count:
|
|
||||||
updated_count = objs_updated
|
|
||||||
|
|
||||||
if updated_count:
|
with transaction.atomic():
|
||||||
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
|
||||||
messages.success(self.request, msg)
|
updated_count = 0
|
||||||
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
for obj in self.cls.objects.filter(pk__in=pk_list):
|
||||||
return redirect(return_url)
|
|
||||||
|
# Update standard fields. If a field is listed in _nullify, delete its value.
|
||||||
|
for name in standard_fields:
|
||||||
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
|
setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
|
||||||
|
elif form.cleaned_data[name] not in (None, ''):
|
||||||
|
setattr(obj, name, form.cleaned_data[name])
|
||||||
|
obj.full_clean()
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
# Update custom fields
|
||||||
|
obj_type = ContentType.objects.get_for_model(self.cls)
|
||||||
|
for name in custom_fields:
|
||||||
|
field = form.fields[name].model
|
||||||
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
|
CustomFieldValue.objects.filter(
|
||||||
|
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||||
|
).delete()
|
||||||
|
elif form.cleaned_data[name] not in [None, '']:
|
||||||
|
try:
|
||||||
|
cfv = CustomFieldValue.objects.get(
|
||||||
|
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||||
|
)
|
||||||
|
except CustomFieldValue.DoesNotExist:
|
||||||
|
cfv = CustomFieldValue(
|
||||||
|
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||||
|
)
|
||||||
|
cfv.value = form.cleaned_data[name]
|
||||||
|
cfv.save()
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if updated_count:
|
||||||
|
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
||||||
|
messages.success(self.request, msg)
|
||||||
|
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
||||||
|
|
||||||
|
return redirect(return_url)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
messages.error(self.request, "{} failed validation: {}".format(obj, e))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
initial_data = request.POST.copy()
|
initial_data = request.POST.copy()
|
||||||
@ -555,53 +580,6 @@ class BulkEditView(View):
|
|||||||
'return_url': return_url,
|
'return_url': return_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
def update_custom_fields(self, pk_list, form, fields, nullified_fields):
|
|
||||||
obj_type = ContentType.objects.get_for_model(self.cls)
|
|
||||||
objs_updated = False
|
|
||||||
|
|
||||||
for name in fields:
|
|
||||||
|
|
||||||
field = form.fields[name].model
|
|
||||||
|
|
||||||
# Setting the field to null
|
|
||||||
if name in form.nullable_fields and name in nullified_fields:
|
|
||||||
|
|
||||||
# Delete all CustomFieldValues for instances of this field belonging to the selected objects.
|
|
||||||
CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
|
|
||||||
objs_updated = True
|
|
||||||
|
|
||||||
# Updating the value of the field
|
|
||||||
elif form.cleaned_data[name] not in [None, '']:
|
|
||||||
|
|
||||||
# Check for zero value (bulk editing)
|
|
||||||
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
|
|
||||||
serialized_value = field.serialize_value(None)
|
|
||||||
else:
|
|
||||||
serialized_value = field.serialize_value(form.cleaned_data[name])
|
|
||||||
|
|
||||||
# Gather any pre-existing CustomFieldValues for the objects being edited.
|
|
||||||
existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
|
|
||||||
|
|
||||||
# Determine which objects have an existing CFV to update and which need a new CFV created.
|
|
||||||
update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
|
|
||||||
create_list = list(set(pk_list) - set(update_list))
|
|
||||||
|
|
||||||
# Creating/updating CFVs
|
|
||||||
if serialized_value:
|
|
||||||
existing_cfvs.update(serialized_value=serialized_value)
|
|
||||||
CustomFieldValue.objects.bulk_create([
|
|
||||||
CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
|
|
||||||
for pk in create_list
|
|
||||||
])
|
|
||||||
|
|
||||||
# Deleting CFVs
|
|
||||||
else:
|
|
||||||
existing_cfvs.delete()
|
|
||||||
|
|
||||||
objs_updated = True
|
|
||||||
|
|
||||||
return len(pk_list) if objs_updated else 0
|
|
||||||
|
|
||||||
|
|
||||||
class BulkDeleteView(View):
|
class BulkDeleteView(View):
|
||||||
"""
|
"""
|
||||||
@ -763,6 +741,26 @@ class ComponentCreateView(View):
|
|||||||
|
|
||||||
if not form.errors:
|
if not form.errors:
|
||||||
self.model.objects.bulk_create(new_components)
|
self.model.objects.bulk_create(new_components)
|
||||||
|
|
||||||
|
# ManyToMany relations are bulk created via the through model
|
||||||
|
m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
|
||||||
|
if m2m_fields:
|
||||||
|
for field in m2m_fields:
|
||||||
|
field_links = []
|
||||||
|
for new_component in new_components:
|
||||||
|
for related_obj in component_form.cleaned_data[field]:
|
||||||
|
# The through model columns are the id's of our M2M relation objects
|
||||||
|
through_kwargs = {}
|
||||||
|
new_component_column = new_component.__class__.__name__ + '_id'
|
||||||
|
related_obj_column = related_obj.__class__.__name__ + '_id'
|
||||||
|
through_kwargs.update({
|
||||||
|
new_component_column.lower(): new_component.id,
|
||||||
|
related_obj_column.lower(): related_obj.id
|
||||||
|
})
|
||||||
|
field_link = getattr(self.model, field).through(**through_kwargs)
|
||||||
|
field_links.append(field_link)
|
||||||
|
getattr(self.model, field).through.objects.bulk_create(field_links)
|
||||||
|
|
||||||
messages.success(request, "Added {} {} to {}.".format(
|
messages.success(request, "Added {} {} to {}.".format(
|
||||||
len(new_components), self.model._meta.verbose_name_plural, parent
|
len(new_components), self.model._meta.verbose_name_plural, parent
|
||||||
))
|
))
|
||||||
@ -779,20 +777,6 @@ class ComponentCreateView(View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class ComponentEditView(ObjectEditView):
|
|
||||||
parent_field = None
|
|
||||||
|
|
||||||
def get_return_url(self, request, obj):
|
|
||||||
return getattr(obj, self.parent_field).get_absolute_url()
|
|
||||||
|
|
||||||
|
|
||||||
class ComponentDeleteView(ObjectDeleteView):
|
|
||||||
parent_field = None
|
|
||||||
|
|
||||||
def get_return_url(self, request, obj):
|
|
||||||
return getattr(obj, self.parent_field).get_absolute_url()
|
|
||||||
|
|
||||||
|
|
||||||
class BulkComponentCreateView(View):
|
class BulkComponentCreateView(View):
|
||||||
"""
|
"""
|
||||||
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
|
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
|
||||||
|
@ -9,7 +9,7 @@ from extras.api.customfields import CustomFieldModelSerializer
|
|||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||||
from virtualization.constants import STATUS_CHOICES
|
from virtualization.constants import VM_STATUS_CHOICES
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
|
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ class ClusterSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cluster
|
model = Cluster
|
||||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
|
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||||
|
|
||||||
|
|
||||||
class NestedClusterSerializer(serializers.ModelSerializer):
|
class NestedClusterSerializer(serializers.ModelSerializer):
|
||||||
@ -77,7 +77,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cluster
|
model = Cluster
|
||||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
|
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -94,7 +94,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class VirtualMachineSerializer(CustomFieldModelSerializer):
|
class VirtualMachineSerializer(CustomFieldModelSerializer):
|
||||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
|
||||||
cluster = NestedClusterSerializer()
|
cluster = NestedClusterSerializer()
|
||||||
role = NestedDeviceRoleSerializer()
|
role = NestedDeviceRoleSerializer()
|
||||||
tenant = NestedTenantSerializer()
|
tenant = NestedTenantSerializer()
|
||||||
@ -107,7 +107,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
|
|||||||
model = VirtualMachine
|
model = VirtualMachine
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||||
'vcpus', 'memory', 'disk', 'comments', 'custom_fields',
|
'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
|
|||||||
model = VirtualMachine
|
model = VirtualMachine
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
|
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
|
||||||
'memory', 'disk', 'comments', 'custom_fields',
|
'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from extras.api.views import CustomFieldModelViewSet
|
from extras.api.views import CustomFieldModelViewSet
|
||||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||||
from virtualization import filters
|
from virtualization import filters
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
from . import serializers
|
from . import serializers
|
||||||
@ -34,7 +32,7 @@ class ClusterGroupViewSet(ModelViewSet):
|
|||||||
serializer_class = serializers.ClusterGroupSerializer
|
serializer_class = serializers.ClusterGroupSerializer
|
||||||
|
|
||||||
|
|
||||||
class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class ClusterViewSet(CustomFieldModelViewSet):
|
||||||
queryset = Cluster.objects.select_related('type', 'group')
|
queryset = Cluster.objects.select_related('type', 'group')
|
||||||
serializer_class = serializers.ClusterSerializer
|
serializer_class = serializers.ClusterSerializer
|
||||||
write_serializer_class = serializers.WritableClusterSerializer
|
write_serializer_class = serializers.WritableClusterSerializer
|
||||||
@ -45,14 +43,14 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
|||||||
# Virtual machines
|
# Virtual machines
|
||||||
#
|
#
|
||||||
|
|
||||||
class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||||
queryset = VirtualMachine.objects.all()
|
queryset = VirtualMachine.objects.all()
|
||||||
serializer_class = serializers.VirtualMachineSerializer
|
serializer_class = serializers.VirtualMachineSerializer
|
||||||
write_serializer_class = serializers.WritableVirtualMachineSerializer
|
write_serializer_class = serializers.WritableVirtualMachineSerializer
|
||||||
filter_class = filters.VirtualMachineFilter
|
filter_class = filters.VirtualMachineFilter
|
||||||
|
|
||||||
|
|
||||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
class InterfaceViewSet(ModelViewSet):
|
||||||
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
|
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
|
||||||
serializer_class = serializers.InterfaceSerializer
|
serializer_class = serializers.InterfaceSerializer
|
||||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED
|
from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED
|
||||||
|
|
||||||
# VirtualMachine statuses (replicated from Device statuses)
|
# VirtualMachine statuses (replicated from Device statuses)
|
||||||
STATUS_CHOICES = [
|
VM_STATUS_CHOICES = [
|
||||||
[STATUS_ACTIVE, 'Active'],
|
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||||
[STATUS_OFFLINE, 'Offline'],
|
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||||
[STATUS_STAGED, 'Staged'],
|
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||||
]
|
]
|
||||||
|
|
||||||
# Bootstrap CSS classes for VirtualMachine statuses
|
# Bootstrap CSS classes for VirtualMachine statuses
|
||||||
|
@ -9,7 +9,7 @@ from dcim.models import DeviceRole, Interface, Platform, Site
|
|||||||
from extras.filters import CustomFieldFilterSet
|
from extras.filters import CustomFieldFilterSet
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.filters import NumericInFilter
|
from utilities.filters import NumericInFilter
|
||||||
from .constants import STATUS_CHOICES
|
from .constants import VM_STATUS_CHOICES
|
||||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
|
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
|
|||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=STATUS_CHOICES,
|
choices=VM_STATUS_CHOICES,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
|
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
@ -17,7 +17,7 @@ from utilities.forms import (
|
|||||||
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
||||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
|
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
|
||||||
)
|
)
|
||||||
from .constants import STATUS_CHOICES
|
from .constants import VM_STATUS_CHOICES
|
||||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
|
|
||||||
VIFACE_FF_CHOICES = (
|
VIFACE_FF_CHOICES = (
|
||||||
@ -300,7 +300,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
|
|
||||||
class VirtualMachineCSVForm(forms.ModelForm):
|
class VirtualMachineCSVForm(forms.ModelForm):
|
||||||
status = CSVChoiceField(
|
status = CSVChoiceField(
|
||||||
choices=STATUS_CHOICES,
|
choices=VM_STATUS_CHOICES,
|
||||||
required=False,
|
required=False,
|
||||||
help_text='Operational status of device'
|
help_text='Operational status of device'
|
||||||
)
|
)
|
||||||
@ -347,7 +347,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
|
|||||||
|
|
||||||
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
|
status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='')
|
||||||
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
||||||
role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False)
|
role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False)
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||||
@ -365,7 +365,7 @@ def vm_status_choices():
|
|||||||
status_counts = {}
|
status_counts = {}
|
||||||
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||||
status_counts[status['status']] = status['count']
|
status_counts[status['status']] = status['count']
|
||||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
|
||||||
|
|
||||||
|
|
||||||
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
|
@ -10,7 +10,7 @@ from django.utils.encoding import python_2_unicode_compatible
|
|||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from extras.models import CustomFieldModel, CustomFieldValue
|
from extras.models import CustomFieldModel, CustomFieldValue
|
||||||
from utilities.models import CreatedUpdatedModel
|
from utilities.models import CreatedUpdatedModel
|
||||||
from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
|
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -190,8 +190,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
status = models.PositiveSmallIntegerField(
|
status = models.PositiveSmallIntegerField(
|
||||||
choices=STATUS_CHOICES,
|
choices=VM_STATUS_CHOICES,
|
||||||
default=STATUS_ACTIVE,
|
default=DEVICE_STATUS_ACTIVE,
|
||||||
verbose_name='Status'
|
verbose_name='Status'
|
||||||
)
|
)
|
||||||
role = models.ForeignKey(
|
role = models.ForeignKey(
|
||||||
@ -282,3 +282,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
return self.primary_ip4
|
return self.primary_ip4
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def site(self):
|
||||||
|
# used when a child compent (eg Interface) needs to know its parent's site but
|
||||||
|
# the parent could be either a device or a virtual machine
|
||||||
|
return self.cluster.site
|
||||||
|
@ -44,7 +44,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:clustertype-list')
|
url = reverse('virtualization-api:clustertype-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(ClusterType.objects.count(), 4)
|
self.assertEqual(ClusterType.objects.count(), 4)
|
||||||
@ -52,6 +52,32 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(clustertype4.name, data['name'])
|
self.assertEqual(clustertype4.name, data['name'])
|
||||||
self.assertEqual(clustertype4.slug, data['slug'])
|
self.assertEqual(clustertype4.slug, data['slug'])
|
||||||
|
|
||||||
|
def test_create_clustertype_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Type 4',
|
||||||
|
'slug': 'test-cluster-type-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Type 5',
|
||||||
|
'slug': 'test-cluster-type-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Type 6',
|
||||||
|
'slug': 'test-cluster-type-6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('virtualization-api:clustertype-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(ClusterType.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_clustertype(self):
|
def test_update_clustertype(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -60,7 +86,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(ClusterType.objects.count(), 3)
|
self.assertEqual(ClusterType.objects.count(), 3)
|
||||||
@ -111,7 +137,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:clustergroup-list')
|
url = reverse('virtualization-api:clustergroup-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(ClusterGroup.objects.count(), 4)
|
self.assertEqual(ClusterGroup.objects.count(), 4)
|
||||||
@ -119,6 +145,32 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(clustergroup4.name, data['name'])
|
self.assertEqual(clustergroup4.name, data['name'])
|
||||||
self.assertEqual(clustergroup4.slug, data['slug'])
|
self.assertEqual(clustergroup4.slug, data['slug'])
|
||||||
|
|
||||||
|
def test_create_clustergroup_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Group 4',
|
||||||
|
'slug': 'test-cluster-group-4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Group 5',
|
||||||
|
'slug': 'test-cluster-group-5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster Group 6',
|
||||||
|
'slug': 'test-cluster-group-6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('virtualization-api:clustergroup-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(ClusterGroup.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_clustergroup(self):
|
def test_update_clustergroup(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -127,7 +179,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(ClusterGroup.objects.count(), 3)
|
self.assertEqual(ClusterGroup.objects.count(), 3)
|
||||||
@ -182,7 +234,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:cluster-list')
|
url = reverse('virtualization-api:cluster-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Cluster.objects.count(), 4)
|
self.assertEqual(Cluster.objects.count(), 4)
|
||||||
@ -191,6 +243,35 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(cluster4.type.pk, data['type'])
|
self.assertEqual(cluster4.type.pk, data['type'])
|
||||||
self.assertEqual(cluster4.group.pk, data['group'])
|
self.assertEqual(cluster4.group.pk, data['group'])
|
||||||
|
|
||||||
|
def test_create_cluster_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster 4',
|
||||||
|
'type': ClusterType.objects.first().pk,
|
||||||
|
'group': ClusterGroup.objects.first().pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster 5',
|
||||||
|
'type': ClusterType.objects.first().pk,
|
||||||
|
'group': ClusterGroup.objects.first().pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Cluster 6',
|
||||||
|
'type': ClusterType.objects.first().pk,
|
||||||
|
'group': ClusterGroup.objects.first().pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('virtualization-api:cluster-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Cluster.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_cluster(self):
|
def test_update_cluster(self):
|
||||||
|
|
||||||
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||||
@ -202,7 +283,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(Cluster.objects.count(), 3)
|
self.assertEqual(Cluster.objects.count(), 3)
|
||||||
@ -230,11 +311,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
|||||||
|
|
||||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||||
cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||||
|
|
||||||
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
|
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
|
||||||
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
|
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
|
||||||
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
|
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
|
||||||
|
|
||||||
def test_get_virtualmachine(self):
|
def test_get_virtualmachine(self):
|
||||||
|
|
||||||
@ -254,11 +335,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
'name': 'Test Virtual Machine 4',
|
'name': 'Test Virtual Machine 4',
|
||||||
'cluster': Cluster.objects.first().pk,
|
'cluster': self.cluster1.pk,
|
||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:virtualmachine-list')
|
url = reverse('virtualization-api:virtualmachine-list')
|
||||||
response = self.client.post(url, data, **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(VirtualMachine.objects.count(), 4)
|
self.assertEqual(VirtualMachine.objects.count(), 4)
|
||||||
@ -266,6 +347,32 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(virtualmachine4.name, data['name'])
|
self.assertEqual(virtualmachine4.name, data['name'])
|
||||||
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
|
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
|
||||||
|
|
||||||
|
def test_create_virtualmachine_bulk(self):
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'name': 'Test Virtual Machine 4',
|
||||||
|
'cluster': self.cluster1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Virtual Machine 5',
|
||||||
|
'cluster': self.cluster1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Test Virtual Machine 6',
|
||||||
|
'cluster': self.cluster1.pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
url = reverse('virtualization-api:virtualmachine-list')
|
||||||
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
|
|
||||||
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(VirtualMachine.objects.count(), 6)
|
||||||
|
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||||
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
|
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||||
|
|
||||||
def test_update_virtualmachine(self):
|
def test_update_virtualmachine(self):
|
||||||
|
|
||||||
cluster2 = Cluster.objects.create(
|
cluster2 = Cluster.objects.create(
|
||||||
@ -279,7 +386,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||||
response = self.client.put(url, data, **self.header)
|
response = self.client.put(url, data, format='json', **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(VirtualMachine.objects.count(), 3)
|
self.assertEqual(VirtualMachine.objects.count(), 3)
|
||||||
|
@ -11,8 +11,8 @@ from dcim.models import Device, Interface
|
|||||||
from dcim.tables import DeviceTable
|
from dcim.tables import DeviceTable
|
||||||
from ipam.models import Service
|
from ipam.models import Service
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
|
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
||||||
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
ObjectEditView, ObjectListView,
|
||||||
)
|
)
|
||||||
from . import filters, forms, tables
|
from . import filters, forms, tables
|
||||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
@ -325,17 +325,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
|
|||||||
template_name = 'virtualization/virtualmachine_component_add.html'
|
template_name = 'virtualization/virtualmachine_component_add.html'
|
||||||
|
|
||||||
|
|
||||||
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
|
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'dcim.change_interface'
|
permission_required = 'dcim.change_interface'
|
||||||
model = Interface
|
model = Interface
|
||||||
parent_field = 'virtual_machine'
|
|
||||||
model_form = forms.InterfaceForm
|
model_form = forms.InterfaceForm
|
||||||
|
|
||||||
|
|
||||||
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||||
permission_required = 'dcim.delete_interface'
|
permission_required = 'dcim.delete_interface'
|
||||||
model = Interface
|
model = Interface
|
||||||
parent_field = 'virtual_machine'
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||||
|
@ -1 +1,2 @@
|
|||||||
|
psycopg2
|
||||||
pycrypto
|
pycrypto
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
Django>=1.11,<2.0
|
Django>=1.11,<2.0
|
||||||
django-cors-headers>=2.1
|
django-cors-headers>=2.1.0
|
||||||
django-debug-toolbar>=1.8
|
django-debug-toolbar>=1.9.0
|
||||||
django-filter>=1.1.0
|
django-filter>=1.1.0
|
||||||
django-mptt==0.8.7
|
django-mptt>=0.9.0
|
||||||
django-rest-swagger>=2.1.0
|
django-rest-swagger>=2.1.0
|
||||||
django-tables2>=1.10.0
|
django-tables2>=1.19.0
|
||||||
djangorestframework>=3.6.4
|
django-timezone-field>=2.0
|
||||||
graphviz>=0.6
|
djangorestframework>=3.7.7
|
||||||
Markdown>=2.6.7
|
graphviz>=0.8.2
|
||||||
natsort>=5.0.0
|
Markdown>=2.6.11
|
||||||
|
natsort>=5.2.0
|
||||||
ncclient==0.5.3
|
ncclient==0.5.3
|
||||||
netaddr==0.7.18
|
netaddr==0.7.18
|
||||||
paramiko>=2.0.0
|
paramiko>=2.4.0
|
||||||
Pillow>=4.0.0
|
Pillow>=5.0.0
|
||||||
psycopg2>=2.7.3
|
psycopg2-binary>=2.7.4
|
||||||
py-gfm>=0.1.3
|
py-gfm>=0.1.3
|
||||||
pycryptodome>=3.4.7
|
pycryptodome>=3.4.11
|
||||||
xmltodict>=0.10.2
|
xmltodict>=0.11.0
|
||||||
|
Loading…
Reference in New Issue
Block a user