Merge branch 'feature' of https://github.com/netbox-community/netbox into feature

# Conflicts:
#	netbox/dcim/tables/template_code.py
#	netbox/netbox/forms.py
#	netbox/templates/dcim/cable_connect.html
#	netbox/templates/dcim/consoleport.html
#	netbox/templates/dcim/consoleserverport.html
#	netbox/templates/dcim/device.html
#	netbox/templates/dcim/device/base.html
#	netbox/templates/dcim/device_edit.html
#	netbox/templates/dcim/interface.html
#	netbox/templates/dcim/rack.html
#	netbox/templates/dcim/rack_edit.html
#	netbox/templates/dcim/site.html
#	netbox/templates/extras/configcontext.html
#	netbox/templates/extras/objectchange.html
#	netbox/templates/generic/object.html
#	netbox/templates/inc/nav_menu.html
#	netbox/templates/ipam/ipaddress_edit.html
#	netbox/templates/ipam/vrf.html
#	netbox/utilities/templates/buttons/export.html
This commit is contained in:
checktheroads
2021-03-17 22:02:43 -07:00
197 changed files with 6374 additions and 2288 deletions

View File

@@ -21,7 +21,7 @@ class NestedProviderSerializer(WritableNestedSerializer):
class Meta:
model = Provider
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
#
@@ -34,7 +34,7 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer):
class Meta:
model = CircuitType
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
class NestedCircuitSerializer(WritableNestedSerializer):
@@ -42,7 +42,7 @@ class NestedCircuitSerializer(WritableNestedSerializer):
class Meta:
model = Circuit
fields = ['id', 'url', 'cid']
fields = ['id', 'url', 'display', 'cid']
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
@@ -51,4 +51,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer):
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'circuit', 'term_side', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied']

View File

@@ -4,10 +4,10 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from netbox.api.serializers import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer
from netbox.api.serializers import (
BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -16,15 +16,15 @@ from .nested_serializers import *
# Providers
#
class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class ProviderSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = Provider
fields = [
'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
]
@@ -39,7 +39,8 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
class Meta:
model = CircuitType
fields = [
'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'circuit_count',
'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
'circuit_count',
]
@@ -50,12 +51,12 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable',
]
class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class CircuitSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)
@@ -67,12 +68,13 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Circuit
fields = [
'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer):
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
@@ -81,7 +83,7 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
]

View File

@@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
from dcim.models import Region, Site
from dcim.models import Region, Site, SiteGroup
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
@@ -37,6 +37,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site',
queryset=Site.objects.all(),
@@ -102,17 +115,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
choices=CircuitStatusChoices,
null_value=None
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region',
@@ -126,6 +128,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
tag = TagFilter()
class Meta:

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import Region, Site
from dcim.models import Region, Site, SiteGroup
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
@@ -142,6 +142,20 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
class CircuitTypeCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
@@ -320,18 +334,26 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
query_params={
'region_id': '$region'
'region_id': '$region',
'group_id': '$site_group',
}
)
class Meta:
model = CircuitTermination
fields = [
'term_side', 'region', 'site', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description',
'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",

View File

@@ -34,4 +34,14 @@ class Migration(migrations.Migration):
name='id',
field=models.BigAutoField(primary_key=True, serialize=False),
),
migrations.AddField(
model_name='circuittermination',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -1,14 +1,12 @@
from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.fields import ASNField
from dcim.models import CableTermination, PathEndpoint
from extras.models import ObjectChange, TaggedItem
from extras.models import ObjectChange
from extras.utils import extras_features
from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from .choices import *
from .querysets import CircuitQuerySet
@@ -61,7 +59,6 @@ class Provider(PrimaryModel):
comments = models.TextField(
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -185,7 +182,6 @@ class Circuit(PrimaryModel):
)
objects = CircuitQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@@ -235,7 +231,8 @@ class Circuit(PrimaryModel):
return self._get_termination('Z')
class CircuitTermination(BigIDModel, PathEndpoint, CableTermination):
@extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
@@ -289,21 +286,14 @@ class CircuitTermination(BigIDModel, PathEndpoint, CableTermination):
def to_objectchange(self, action):
# Annotate the parent Circuit
try:
related_object = self.circuit
circuit = self.circuit
except Circuit.DoesNotExist:
# Parent circuit has been deleted
related_object = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=related_object,
object_data=serialize_object(self)
)
circuit = None
return super().to_objectchange(action, related_object=circuit)
@property
def parent(self):
def parent_object(self):
return self.circuit
def get_peer_termination(self):

View File

@@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider
@@ -60,9 +60,7 @@ class CircuitTable(BaseTable):
linkify=True
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
a_side = tables.Column(
verbose_name='A Side'
)

View File

@@ -17,7 +17,7 @@ class AppTest(APITestCase):
class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Provider 4',
@@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
create_data = (
{
'name': 'Circuit Type 4',
@@ -81,7 +81,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
brief_fields = ['cid', 'id', 'url']
brief_fields = ['cid', 'display', 'id', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -129,7 +129,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination
brief_fields = ['_occupied', 'cable', 'circuit', 'id', 'term_side', 'url']
brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
@classmethod
def setUpTestData(cls):

View File

@@ -3,7 +3,7 @@ from django.test import TestCase
from circuits.choices import *
from circuits.filters import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Cable, Region, Site
from dcim.models import Cable, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
@@ -27,13 +27,20 @@ class ProviderTestCase(TestCase):
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
)
Site.objects.bulk_create(sites)
@@ -74,13 +81,6 @@ class ProviderTestCase(TestCase):
params = {'account': ['1234', '2345']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -88,6 +88,20 @@ class ProviderTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTypeTestCase(TestCase):
queryset = CircuitType.objects.all()
@@ -127,14 +141,21 @@ class CircuitTestCase(TestCase):
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -223,6 +244,13 @@ class CircuitTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Circuit Type 6,circuit-type-6",
)
cls.bulk_edit_data = {
'description': 'Foo',
}
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit

View File

@@ -1,7 +1,7 @@
from django.urls import path
from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -18,11 +18,13 @@ urlpatterns = [
path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
@@ -38,6 +40,7 @@ urlpatterns = [
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations

View File

@@ -107,6 +107,15 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
table = tables.CircuitTypeTable
class CircuitTypeBulkEditView(generic.BulkEditView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filters.CircuitTypeFilterSet
table = tables.CircuitTypeTable
form = forms.CircuitTypeBulkEditForm
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from dcim import models
from netbox.api import WritableNestedSerializer
from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
@@ -27,7 +27,7 @@ __all__ = [
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer',
'NestedRackGroupSerializer',
'NestedLocationSerializer',
'NestedRackReservationSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
@@ -35,6 +35,7 @@ __all__ = [
'NestedRearPortTemplateSerializer',
'NestedRegionSerializer',
'NestedSiteSerializer',
'NestedSiteGroupSerializer',
'NestedVirtualChassisSerializer',
]
@@ -50,7 +51,17 @@ class NestedRegionSerializer(WritableNestedSerializer):
class Meta:
model = models.Region
fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
class NestedSiteGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.SiteGroup
fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
class NestedSiteSerializer(WritableNestedSerializer):
@@ -58,21 +69,21 @@ class NestedSiteSerializer(WritableNestedSerializer):
class Meta:
model = models.Site
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'display', 'name', 'slug']
#
# Racks
#
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
class NestedLocationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
rack_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.RackGroup
fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth']
model = models.Location
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth']
class NestedRackRoleSerializer(WritableNestedSerializer):
@@ -81,7 +92,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class Meta:
model = models.RackRole
fields = ['id', 'url', 'name', 'slug', 'rack_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count']
class NestedRackSerializer(WritableNestedSerializer):
@@ -90,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer):
class Meta:
model = models.Rack
fields = ['id', 'url', 'name', 'display_name', 'device_count']
fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
@@ -99,7 +110,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
class Meta:
model = models.RackReservation
fields = ['id', 'url', 'user', 'units']
fields = ['id', 'url', 'display', 'user', 'units']
def get_user(self, obj):
return obj.user.username
@@ -115,7 +126,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
class Meta:
model = models.Manufacturer
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count']
class NestedDeviceTypeSerializer(WritableNestedSerializer):
@@ -125,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
class Meta:
model = models.DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
@@ -133,7 +144,7 @@ class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.ConsolePortTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
@@ -141,7 +152,7 @@ class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.ConsoleServerPortTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
@@ -149,7 +160,7 @@ class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerPortTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
@@ -157,7 +168,7 @@ class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerOutletTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
@@ -165,7 +176,7 @@ class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.InterfaceTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
@@ -173,7 +184,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.RearPortTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
@@ -181,7 +192,7 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.FrontPortTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
@@ -189,7 +200,7 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.DeviceBayTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
#
@@ -203,7 +214,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
class Meta:
model = models.DeviceRole
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedPlatformSerializer(WritableNestedSerializer):
@@ -213,7 +224,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
class Meta:
model = models.Platform
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedDeviceSerializer(WritableNestedSerializer):
@@ -221,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
class Meta:
model = models.Device
fields = ['id', 'url', 'name', 'display_name']
fields = ['id', 'url', 'display', 'name', 'display_name']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
@@ -230,7 +241,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
class Meta:
model = models.ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedConsolePortSerializer(WritableNestedSerializer):
@@ -239,7 +250,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
class Meta:
model = models.ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerOutletSerializer(WritableNestedSerializer):
@@ -248,7 +259,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerPortSerializer(WritableNestedSerializer):
@@ -257,7 +268,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerPort
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedInterfaceSerializer(WritableNestedSerializer):
@@ -266,7 +277,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
class Meta:
model = models.Interface
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedRearPortSerializer(WritableNestedSerializer):
@@ -275,7 +286,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
class Meta:
model = models.RearPort
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedFrontPortSerializer(WritableNestedSerializer):
@@ -284,7 +295,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
class Meta:
model = models.FrontPort
fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedDeviceBaySerializer(WritableNestedSerializer):
@@ -293,7 +304,7 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
class Meta:
model = models.DeviceBay
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'display', 'device', 'name']
class NestedInventoryItemSerializer(WritableNestedSerializer):
@@ -303,19 +314,19 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
class Meta:
model = models.InventoryItem
fields = ['id', 'url', 'device', 'name', '_depth']
fields = ['id', 'url', 'display', 'device', 'name', '_depth']
#
# Cables
#
class NestedCableSerializer(serializers.ModelSerializer):
class NestedCableSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = models.Cable
fields = ['id', 'url', 'label']
fields = ['id', 'url', 'display', 'label']
#
@@ -342,7 +353,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerPanel
fields = ['id', 'url', 'name', 'powerfeed_count']
fields = ['id', 'url', 'display', 'name', 'powerfeed_count']
class NestedPowerFeedSerializer(WritableNestedSerializer):
@@ -350,4 +361,4 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerFeed
fields = ['id', 'url', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']

View File

@@ -6,20 +6,13 @@ from rest_framework.validators import UniqueTogetherValidator
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from netbox.api.serializers import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from dcim.models import *
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
from netbox.api.serializers import (
NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
WritableNestedSerializer,
)
from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer
@@ -49,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer):
return None
class ConnectedEndpointSerializer(CustomFieldModelSerializer):
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
@@ -89,15 +82,29 @@ class RegionSerializer(NestedGroupModelSerializer):
class Meta:
model = Region
fields = [
'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
'site_count', '_depth',
]
class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedRegionSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = SiteGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
'site_count', '_depth',
]
class SiteSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
@@ -110,10 +117,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Site
fields = [
'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
]
@@ -121,17 +128,17 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
# Racks
#
class RackGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = NestedSiteSerializer()
parent = NestedRackGroupSerializer(required=False, allow_null=True)
parent = NestedLocationSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackGroup
model = Location
fields = [
'id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
'rack_count', '_depth',
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created',
'last_updated', 'rack_count', '_depth',
]
@@ -142,15 +149,15 @@ class RackRoleSerializer(OrganizationalModelSerializer):
class Meta:
model = RackRole
fields = [
'id', 'url', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
'rack_count',
]
class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class RackSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True)
@@ -163,21 +170,22 @@ class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
'id', 'url', 'display', 'name', 'facility_id', 'display_name', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This
# Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
# prevents facility_id from being interpreted as a required field.
validators = [
UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name'))
UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name'))
]
def validate(self, data):
# Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta.
# Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta.
if data.get('facility_id', None):
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id'))
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id'))
validator(data, self)
# Enforce model validation
@@ -197,7 +205,7 @@ class RackUnitSerializer(serializers.Serializer):
occupied = serializers.BooleanField(read_only=True)
class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class RackReservationSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer()
user = NestedUserSerializer()
@@ -205,7 +213,10 @@ class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializ
class Meta:
model = RackReservation
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields']
fields = [
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
'custom_fields',
]
class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -257,12 +268,12 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
class Meta:
model = Manufacturer
fields = [
'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'devicetype_count',
'inventoryitem_count', 'platform_count',
'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count',
]
class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class DeviceTypeSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
@@ -271,9 +282,9 @@ class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = DeviceType
fields = [
'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height',
'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count',
]
@@ -288,7 +299,9 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated']
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
]
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -302,7 +315,9 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated']
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
]
class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -317,8 +332,8 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = [
'id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'created', 'last_updated',
]
@@ -342,8 +357,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = [
'id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'created', 'last_updated',
]
@@ -355,7 +370,8 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
'last_updated',
]
@@ -367,7 +383,8 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = RearPortTemplate
fields = [
'id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created',
'last_updated',
]
@@ -380,8 +397,8 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = FrontPortTemplate
fields = [
'id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position',
'description', 'created', 'last_updated',
]
@@ -391,7 +408,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'url', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
#
@@ -406,8 +423,8 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
class Meta:
model = DeviceRole
fields = [
'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'last_updated',
'device_count', 'virtualmachine_count',
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created',
'last_updated', 'device_count', 'virtualmachine_count',
]
@@ -420,18 +437,19 @@ class PlatformSerializer(OrganizationalModelSerializer):
class Meta:
model = Platform
fields = [
'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'custom_fields',
'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class DeviceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
@@ -445,10 +463,10 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Device
fields = [
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -481,10 +499,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -496,7 +514,11 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
#
# Device components
#
class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -504,18 +526,23 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial
allow_blank=True,
required=False
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -523,18 +550,23 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer,
allow_blank=True,
required=False
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -557,13 +589,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer,
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -576,16 +608,17 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
class Meta:
model = PowerPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -601,10 +634,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
class Meta:
model = Interface
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer',
'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
'_occupied',
]
def validate(self, data):
@@ -621,7 +655,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
return super().validate(data)
class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -630,8 +664,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cus
class Meta:
model = RearPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable',
'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
@@ -643,10 +677,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta:
model = RearPort
fields = ['id', 'url', 'name', 'label']
fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -656,13 +690,13 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu
class Meta:
model = FrontPort
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class DeviceBaySerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -670,8 +704,8 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = DeviceBay
fields = [
'id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
'custom_fields', 'created', 'last_updated',
]
@@ -679,7 +713,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
# Inventory items
#
class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
@@ -690,8 +724,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
class Meta:
model = InventoryItem
fields = [
'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
]
@@ -699,7 +733,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
# Cables
#
class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class CableSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -715,7 +749,7 @@ class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
'custom_fields',
]
@@ -829,24 +863,24 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
# Virtual chassis
#
class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class VirtualChassisSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
#
# Power panels
#
class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class PowerPanelSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
location = NestedLocationSerializer(
required=False,
allow_null=True,
default=None
@@ -855,15 +889,10 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count']
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
class PowerFeedSerializer(
TaggedObjectSerializer,
CableTerminationSerializer,
ConnectedEndpointSerializer,
CustomFieldModelSerializer
):
class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@@ -892,8 +921,8 @@ class PowerFeedSerializer(
class Meta:
model = PowerFeed
fields = [
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]

View File

@@ -7,10 +7,11 @@ router.APIRootView = views.DCIMRootView
# Sites
router.register('regions', views.RegionViewSet)
router.register('site-groups', views.SiteGroupViewSet)
router.register('sites', views.SiteViewSet)
# Racks
router.register('rack-groups', views.RackGroupViewSet)
router.register('locations', views.LocationViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
router.register('rack-reservations', views.RackReservationViewSet)

View File

@@ -16,13 +16,7 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit
from dcim import filters
from dcim.models import (
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN
from netbox.api.views import ModelViewSet
@@ -111,6 +105,22 @@ class RegionViewSet(CustomFieldModelViewSet):
filterset_class = filters.RegionFilterSet
#
# Site groups
#
class SiteGroupViewSet(CustomFieldModelViewSet):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
'group',
'site_count',
cumulative=True
)
serializer_class = serializers.SiteGroupSerializer
filterset_class = filters.SiteGroupFilterSet
#
# Sites
#
@@ -134,16 +144,16 @@ class SiteViewSet(CustomFieldModelViewSet):
# Rack groups
#
class RackGroupViewSet(CustomFieldModelViewSet):
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
class LocationViewSet(CustomFieldModelViewSet):
queryset = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'group',
'location',
'rack_count',
cumulative=True
).prefetch_related('site')
serializer_class = serializers.RackGroupSerializer
filterset_class = filters.RackGroupFilterSet
serializer_class = serializers.LocationSerializer
filterset_class = filters.LocationFilterSet
#
@@ -164,7 +174,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags'
'site', 'location__site', 'role', 'tenant', 'tags'
).annotate(
device_count=count_related(Device, 'rack'),
powerfeed_count=count_related(PowerFeed, 'rack')
@@ -345,7 +355,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
)
filterset_class = filters.DeviceFilterSet
@@ -522,7 +532,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet
@@ -619,7 +629,7 @@ class VirtualChassisViewSet(ModelViewSet):
class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
'site', 'location'
).annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)

View File

@@ -217,6 +217,29 @@ class ConsolePortTypeChoices(ChoiceSet):
)
class ConsolePortSpeedChoices(ChoiceSet):
SPEED_1200 = 1200
SPEED_2400 = 2400
SPEED_4800 = 4800
SPEED_9600 = 9600
SPEED_19200 = 19200
SPEED_38400 = 38400
SPEED_57600 = 57600
SPEED_115200 = 115200
CHOICES = (
(SPEED_1200, '1200 bps'),
(SPEED_2400, '2400 bps'),
(SPEED_4800, '4800 bps'),
(SPEED_9600, '9600 bps'),
(SPEED_19200, '19.2 kbps'),
(SPEED_38400, '38.4 kbps'),
(SPEED_57600, '57.6 kbps'),
(SPEED_115200, '115.2 kbps'),
)
#
# PowerPorts
#
@@ -686,6 +709,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
TYPE_32GFC_SFP28 = '32gfc-sfp28'
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
TYPE_128GFC_QSFP28 = '128gfc-sfp28'
# InfiniBand
@@ -801,6 +825,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
(TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
)
),

View File

@@ -12,13 +12,7 @@ from utilities.filters import (
from virtualization.models import Cluster
from .choices import *
from .constants import *
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from .models import *
__all__ = (
@@ -40,6 +34,7 @@ __all__ = (
'InterfaceFilterSet',
'InterfaceTemplateFilterSet',
'InventoryItemFilterSet',
'LocationFilterSet',
'ManufacturerFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet',
@@ -51,13 +46,13 @@ __all__ = (
'PowerPortFilterSet',
'PowerPortTemplateFilterSet',
'RackFilterSet',
'RackGroupFilterSet',
'RackReservationFilterSet',
'RackRoleFilterSet',
'RearPortFilterSet',
'RearPortTemplateFilterSet',
'RegionFilterSet',
'SiteFilterSet',
'SiteGroupFilterSet',
'VirtualChassisFilterSet',
)
@@ -79,6 +74,23 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description']
class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label='Parent site group (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=SiteGroup.objects.all(),
to_field_name='slug',
label='Parent site group (slug)',
)
class Meta:
model = SiteGroup
fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -96,11 +108,22 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='group',
lookup_expr='in',
label='Group (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
lookup_expr='in',
to_field_name='slug',
label='Group (slug)',
)
tag = TagFilter()
class Meta:
@@ -131,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
return queryset.filter(qs_filter)
class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -145,6 +168,19 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -156,18 +192,18 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
label='Site (slug)',
)
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
queryset=Location.objects.all(),
label='Rack group (ID)',
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=RackGroup.objects.all(),
queryset=Location.objects.all(),
to_field_name='slug',
label='Rack group (slug)',
)
class Meta:
model = RackGroup
model = Location
fields = ['id', 'name', 'slug', 'description']
@@ -196,6 +232,19 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -206,18 +255,18 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
to_field_name='slug',
label='Site (slug)',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='group',
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
label='Rack group (ID)',
label='Location (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='group',
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
to_field_name='slug',
label='Rack group (slug)',
label='Location (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=RackStatusChoices,
@@ -283,18 +332,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
to_field_name='slug',
label='Site (slug)',
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='rack__group',
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',
lookup_expr='in',
label='Rack group (ID)',
label='Location (ID)',
)
group = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='rack__group',
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',
lookup_expr='in',
to_field_name='slug',
label='Rack group (slug)',
label='Location (slug)',
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
@@ -565,6 +614,19 @@ class DeviceFilterSet(
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -575,11 +637,11 @@ class DeviceFilterSet(
to_field_name='slug',
label='Site name (slug)',
)
rack_group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='rack__group',
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
label='Rack group (ID)',
label='Location (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
@@ -721,6 +783,19 @@ class DeviceComponentFilterSet(CustomFieldModelFilterSet):
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
@@ -844,6 +919,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
method='filter_kind',
label='Kind of interface',
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=Interface.objects.all(),
label='Parent interface (ID)',
)
lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag',
queryset=Interface.objects.all(),
@@ -1038,6 +1118,19 @@ class VirtualChassisFilterSet(BaseFilterSet):
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='master__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='master__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site',
queryset=Site.objects.all(),
@@ -1226,6 +1319,19 @@ class PowerPanelFilterSet(BaseFilterSet):
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -1236,9 +1342,9 @@ class PowerPanelFilterSet(BaseFilterSet):
to_field_name='slug',
label='Site name (slug)',
)
rack_group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='rack_group',
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
label='Rack group (ID)',
)
@@ -1281,6 +1387,19 @@ class PowerFeedFilterSet(
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='power_panel__site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='power_panel__site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site',
queryset=Site.objects.all(),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0124_mark_connected'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='speed',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='consoleserverport',
name='speed',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0125_console_port_speed'),
]
operations = [
migrations.RenameModel(
old_name='RackGroup',
new_name='Location',
),
migrations.AlterModelOptions(
name='rack',
options={'ordering': ('site', 'location', '_name', 'pk')},
),
migrations.AlterField(
model_name='location',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='dcim.site'),
),
migrations.RenameField(
model_name='powerpanel',
old_name='rack_group',
new_name='location',
),
migrations.RenameField(
model_name='rack',
old_name='group',
new_name='location',
),
migrations.AlterUniqueTogether(
name='rack',
unique_together={('location', 'facility_id'), ('location', 'name')},
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0126_rename_rackgroup_location'),
]
operations = [
migrations.AddField(
model_name='device',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.location'),
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
from django.db.models import Subquery, OuterRef
def populate_device_location(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
Device.objects.filter(rack__isnull=False).update(
location_id=Subquery(
Device.objects.filter(pk=OuterRef('pk')).values('rack__location_id')[:1]
)
)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0127_device_location'),
]
operations = [
migrations.RunPython(
code=populate_device_location
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0128_device_location_populate'),
]
operations = [
migrations.AddField(
model_name='interface',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'),
),
]

View File

@@ -0,0 +1,39 @@
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0129_interface_parent'),
]
operations = [
migrations.CreateModel(
name='SiteGroup',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.sitegroup')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='site',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.sitegroup'),
),
]

View File

@@ -34,12 +34,13 @@ __all__ = (
'PowerPort',
'PowerPortTemplate',
'Rack',
'RackGroup',
'Location',
'RackReservation',
'RackRole',
'RearPort',
'RearPortTemplate',
'Region',
'Site',
'SiteGroup',
'VirtualChassis',
)

View File

@@ -6,13 +6,11 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models import Sum
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from extras.models import TaggedItem
from extras.utils import extras_features
from netbox.models import BigIDModel, PrimaryModel
from utilities.fields import ColorField
@@ -108,7 +106,6 @@ class Cable(PrimaryModel):
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()

View File

@@ -4,13 +4,11 @@ from django.db import models
from dcim.choices import *
from dcim.constants import *
from extras.models import ObjectChange
from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggingMixin
from netbox.models import ChangeLoggedModel
from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
)
@@ -28,7 +26,7 @@ __all__ = (
)
class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel):
class ComponentTemplateModel(ChangeLoggedModel):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
@@ -75,16 +73,10 @@ class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel):
except ObjectDoesNotExist:
# The parent DeviceType has already been deleted
device_type = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=device_type,
object_data=serialize_object(self)
)
return super().to_objectchange(action, related_object=device_type)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class ConsolePortTemplate(ComponentTemplateModel):
"""
A template for a ConsolePort to be created for a new Device.
@@ -108,7 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class ConsoleServerPortTemplate(ComponentTemplateModel):
"""
A template for a ConsoleServerPort to be created for a new Device.
@@ -132,7 +124,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class PowerPortTemplate(ComponentTemplateModel):
"""
A template for a PowerPort to be created for a new Device.
@@ -179,7 +171,7 @@ class PowerPortTemplate(ComponentTemplateModel):
})
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class PowerOutletTemplate(ComponentTemplateModel):
"""
A template for a PowerOutlet to be created for a new Device.
@@ -231,7 +223,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class InterfaceTemplate(ComponentTemplateModel):
"""
A template for a physical data interface on a new Device.
@@ -266,7 +258,7 @@ class InterfaceTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class FrontPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the front of a new Device.
@@ -327,7 +319,7 @@ class FrontPortTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class RearPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the rear of a new Device.
@@ -358,7 +350,7 @@ class RearPortTemplate(ComponentTemplateModel):
)
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('webhooks')
class DeviceBayTemplate(ComponentTemplateModel):
"""
A template for a DeviceBay to be created for a new parent Device.

View File

@@ -6,12 +6,10 @@ from django.db import models
from django.db.models import Sum
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.fields import NaturalOrderingField
@@ -19,7 +17,6 @@ from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object
__all__ = (
@@ -82,17 +79,11 @@ class ComponentModel(PrimaryModel):
except ObjectDoesNotExist:
# The parent Device has already been deleted
device = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=device,
object_data=serialize_object(self)
)
return super().to_objectchange(action, related_object=device)
@property
def parent(self):
return getattr(self, 'device', None)
def parent_object(self):
return self.device
class CableTermination(models.Model):
@@ -159,6 +150,10 @@ class CableTermination(models.Model):
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
class PathEndpoint(models.Model):
"""
@@ -213,8 +208,8 @@ class PathEndpoint(models.Model):
# Console ports
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@@ -224,9 +219,14 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
blank=True,
help_text='Physical port type'
)
tags = TaggableManager(through=TaggedItem)
speed = models.PositiveSmallIntegerField(
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
help_text='Port speed in bits per second'
)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
class Meta:
ordering = ('device', '_name')
@@ -241,6 +241,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
self.name,
self.label,
self.type,
self.speed,
self.mark_connected,
self.description,
)
@@ -250,8 +251,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
# Console server ports
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@@ -261,9 +262,14 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
blank=True,
help_text='Physical port type'
)
tags = TaggableManager(through=TaggedItem)
speed = models.PositiveSmallIntegerField(
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
help_text='Port speed in bits per second'
)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
class Meta:
ordering = ('device', '_name')
@@ -278,6 +284,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
self.name,
self.label,
self.type,
self.speed,
self.mark_connected,
self.description,
)
@@ -287,8 +294,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
# Power ports
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class PowerPort(CableTermination, PathEndpoint, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerPort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@@ -310,7 +317,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
validators=[MinValueValidator(1)],
help_text="Allocated power draw (watts)"
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
@@ -399,8 +405,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
# Power outlets
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@@ -423,7 +429,6 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
blank=True,
help_text="Phase (for three-phase feeds)"
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
@@ -500,8 +505,8 @@ class BaseInterface(models.Model):
return super().save(*args, **kwargs)
@extras_features('custom_fields', 'export_templates', 'webhooks')
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -512,6 +517,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
max_length=100,
blank=True
)
parent = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
related_name='child_interfaces',
null=True,
blank=True,
verbose_name='Parent interface'
)
lag = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
@@ -549,11 +562,10 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
object_id_field='assigned_object_id',
related_query_name='interface'
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'name', 'label', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', 'mgmt_only',
'description', 'mode',
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
'mgmt_only', 'description', 'mode',
]
class Meta:
@@ -568,6 +580,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
self.device.identifier if self.device else None,
self.name,
self.label,
self.parent.name if self.parent else None,
self.lag.name if self.lag else None,
self.get_type_display(),
self.enabled,
@@ -591,6 +604,27 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
"Disconnect the interface or choose a suitable type."
})
# An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
f"({self.parent.device})."
})
elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
f"is not part of virtual chassis {self.device.virtual_chassis}."
})
# A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
# A virtual interface cannot be a parent interface
if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL:
raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."})
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None:
@@ -612,16 +646,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
"device, or it must be global".format(self.untagged_vlan)
})
@property
def parent(self):
return self.device
@property
def is_connectable(self):
return self.type not in NONCONNECTABLE_IFACE_TYPES
@@ -647,8 +677,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
# Pass-through ports
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class FrontPort(CableTermination, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class FrontPort(ComponentModel, CableTermination):
"""
A pass-through port on the front of a Device.
"""
@@ -668,7 +698,6 @@ class FrontPort(CableTermination, ComponentModel):
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
@@ -713,8 +742,8 @@ class FrontPort(CableTermination, ComponentModel):
})
@extras_features('custom_fields', 'export_templates', 'webhooks')
class RearPort(CableTermination, ComponentModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class RearPort(ComponentModel, CableTermination):
"""
A pass-through port on the rear of a Device.
"""
@@ -729,7 +758,6 @@ class RearPort(CableTermination, ComponentModel):
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
@@ -767,7 +795,7 @@ class RearPort(CableTermination, ComponentModel):
# Device bays
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
@@ -779,7 +807,6 @@ class DeviceBay(ComponentModel):
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
@@ -827,7 +854,7 @@ class DeviceBay(ComponentModel):
# Inventory items
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
@@ -872,8 +899,6 @@ class InventoryItem(MPTTModel, ComponentModel):
help_text='This item was automatically discovered'
)
tags = TaggableManager(through=TaggedItem)
objects = TreeManager()
csv_headers = [

View File

@@ -9,11 +9,10 @@ from django.db import models
from django.db.models import F, ProtectedError
from django.urls import reverse
from django.utils.safestring import mark_safe
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel, TaggedItem
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel
@@ -136,7 +135,6 @@ class DeviceType(PrimaryModel):
comments = models.TextField(
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -517,6 +515,13 @@ class Device(PrimaryModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='devices'
)
location = models.ForeignKey(
to='dcim.Location',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.PROTECT,
@@ -594,16 +599,15 @@ class Device(PrimaryModel, ConfigContextModel):
object_id_field='assigned_object_id',
related_query_name='device'
)
tags = TaggableManager(through=TaggedItem)
objects = ConfigContextModelQuerySet.as_manager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
'site', 'location', 'rack_name', 'position', 'face', 'comments',
]
clone_fields = [
'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster',
]
class Meta:
@@ -640,11 +644,17 @@ class Device(PrimaryModel, ConfigContextModel):
def clean(self):
super().clean()
# Validate site/rack combination
# Validate site/location/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
if self.rack and self.location and self.rack.location != self.location:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
})
elif self.rack:
self.location = self.rack.location
if self.rack is None:
if self.face:
@@ -799,7 +809,7 @@ class Device(PrimaryModel, ConfigContextModel):
self.asset_tag,
self.get_status_display(),
self.site.name,
self.rack.group.name if self.rack and self.rack.group else None,
self.rack.location.name if self.rack and self.rack.location else None,
self.rack.name if self.rack else None,
self.position,
self.get_face_display(),
@@ -903,7 +913,6 @@ class VirtualChassis(PrimaryModel):
max_length=30,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()

View File

@@ -2,11 +2,9 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from extras.models import TaggedItem
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
@@ -32,8 +30,8 @@ class PowerPanel(PrimaryModel):
to='Site',
on_delete=models.PROTECT
)
rack_group = models.ForeignKey(
to='RackGroup',
location = models.ForeignKey(
to='dcim.Location',
on_delete=models.PROTECT,
blank=True,
null=True
@@ -41,11 +39,10 @@ class PowerPanel(PrimaryModel):
name = models.CharField(
max_length=100
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name']
csv_headers = ['site', 'location', 'name']
class Meta:
ordering = ['site', 'name']
@@ -60,17 +57,17 @@ class PowerPanel(PrimaryModel):
def to_csv(self):
return (
self.site.name,
self.rack_group.name if self.rack_group else None,
self.location.name if self.location else None,
self.name,
)
def clean(self):
super().clean()
# RackGroup must belong to assigned Site
if self.rack_group and self.rack_group.site != self.site:
# Location must belong to assigned Site
if self.location and self.location.site != self.site:
raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
self.rack_group, self.rack_group.site, self.site
self.location, self.location.site, self.site
))
@@ -133,12 +130,11 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
comments = models.TextField(
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'comments',
]
clone_fields = [
@@ -160,7 +156,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
return (
self.power_panel.site.name,
self.power_panel.name,
self.rack.group.name if self.rack and self.rack.group else None,
self.rack.location.name if self.rack and self.rack.location else None,
self.rack.name if self.rack else None,
self.name,
self.get_status_display(),
@@ -201,7 +197,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
super().save(*args, **kwargs)
@property
def parent(self):
def parent_object(self):
return self.power_panel
def get_type_class(self):

View File

@@ -10,27 +10,22 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Sum
from django.urls import reverse
from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from dcim.elevations import RackElevationSVG
from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import array_to_string, serialize_object
from utilities.utils import array_to_string
from .device_components import PowerOutlet, PowerPort
from .devices import Device
from .power import PowerFeed
__all__ = (
'Rack',
'RackGroup',
'RackReservation',
'RackRole',
)
@@ -40,66 +35,6 @@ __all__ = (
# Racks
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class RackGroup(NestedGroupModel):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
"""
name = models.CharField(
max_length=100
)
slug = models.SlugField(
max_length=100
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='rack_groups'
)
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
description = models.CharField(
max_length=200,
blank=True
)
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
def to_csv(self):
return (
self.site,
self.parent.name if self.parent else '',
self.name,
self.slug,
self.description,
)
def clean(self):
super().clean()
# Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
@extras_features('custom_fields', 'export_templates', 'webhooks')
class RackRole(OrganizationalModel):
"""
@@ -147,7 +82,7 @@ class RackRole(OrganizationalModel):
class Rack(PrimaryModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup.
Each Rack is assigned to a Site and (optionally) a Location.
"""
name = models.CharField(
max_length=100
@@ -169,13 +104,12 @@ class Rack(PrimaryModel):
on_delete=models.PROTECT,
related_name='racks'
)
group = models.ForeignKey(
to='dcim.RackGroup',
location = models.ForeignKey(
to='dcim.Location',
on_delete=models.SET_NULL,
related_name='racks',
blank=True,
null=True,
help_text='Assigned group'
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -254,25 +188,24 @@ class Rack(PrimaryModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
]
clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit',
]
class Meta:
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique
unique_together = (
# Name and facility_id must be unique *only* within a RackGroup
('group', 'name'),
('group', 'facility_id'),
# Name and facility_id must be unique *only* within a Location
('location', 'name'),
('location', 'facility_id'),
)
def __str__(self):
@@ -284,9 +217,9 @@ class Rack(PrimaryModel):
def clean(self):
super().clean()
# Validate group/site assignment
if self.site and self.group and self.group.site != self.site:
raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
# Validate location/site assignment
if self.site and self.location and self.location.site != self.site:
raise ValidationError(f"Assigned location must belong to parent site ({self.site}).")
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
@@ -309,17 +242,17 @@ class Rack(PrimaryModel):
min_height
)
})
# Validate that Rack was assigned a group of its same site, if applicable
if self.group:
if self.group.site != self.site:
# Validate that Rack was assigned a Location of its same site, if applicable
if self.location:
if self.location.site != self.site:
raise ValidationError({
'group': "Rack group must be from the same site, {}.".format(self.site)
'location': f"Location must be from the same site, {self.site}."
})
def to_csv(self):
return (
self.site.name,
self.group.name if self.group else None,
self.location.name if self.location else None,
self.name,
self.facility_id,
self.tenant.name if self.tenant else None,
@@ -561,11 +494,10 @@ class RackReservation(PrimaryModel):
description = models.CharField(
max_length=200
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description']
class Meta:
ordering = ['created', 'pk']
@@ -606,7 +538,7 @@ class RackReservation(PrimaryModel):
def to_csv(self):
return (
self.rack.site.name,
self.rack.group if self.rack.group else None,
self.rack.location if self.rack.location else None,
self.rack.name,
','.join([str(u) for u in self.units]),
self.tenant.name if self.tenant else None,

View File

@@ -1,24 +1,23 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import serialize_object
__all__ = (
'Location',
'Region',
'Site',
'SiteGroup',
)
@@ -29,7 +28,9 @@ __all__ = (
@extras_features('custom_fields', 'export_templates', 'webhooks')
class Region(NestedGroupModel):
"""
Sites can be grouped within geographic Regions.
A region represents a geographic collection of sites. For example, you might create regions representing countries,
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
also considered to be members of its parent and ancestor region(s).
"""
parent = TreeForeignKey(
to='self',
@@ -72,6 +73,58 @@ class Region(NestedGroupModel):
).count()
#
# Site groups
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class SiteGroup(NestedGroupModel):
"""
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
nested recursively to form a hierarchy.
"""
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
name = models.CharField(
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
csv_headers = ['name', 'slug', 'parent', 'description']
def get_absolute_url(self):
return "{}?group={}".format(reverse('dcim:site_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.parent.name if self.parent else None,
self.description,
)
def get_site_count(self):
return Site.objects.filter(
Q(group=self) |
Q(group__in=self.get_descendants())
).count()
#
# Sites
#
@@ -107,6 +160,13 @@ class Site(PrimaryModel):
blank=True,
null=True
)
group = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.SET_NULL,
related_name='sites',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -172,16 +232,16 @@ class Site(PrimaryModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments',
]
clone_fields = [
'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
]
@@ -200,6 +260,7 @@ class Site(PrimaryModel):
self.slug,
self.get_status_display(),
self.region.name if self.region else None,
self.group.name if self.group else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
@@ -217,3 +278,66 @@ class Site(PrimaryModel):
def get_status_class(self):
return SiteStatusChoices.CSS_CLASSES.get(self.status)
#
# Locations
#
@extras_features('custom_fields', 'export_templates', 'webhooks')
class Location(NestedGroupModel):
"""
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
site, or a room within a building, for example.
"""
name = models.CharField(
max_length=100
)
slug = models.SlugField(
max_length=100
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='locations'
)
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
description = models.CharField(
max_length=200,
blank=True
)
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
def get_absolute_url(self):
return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
def to_csv(self):
return (
self.site,
self.parent.name if self.parent else '',
self.name,
self.slug,
self.description,
)
def clean(self):
super().clean()
# Parent Location (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")

View File

@@ -7,7 +7,7 @@ from django.db import transaction
from django.dispatch import receiver
from .choices import CableStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
def create_cablepath(node):
@@ -37,36 +37,30 @@ def rebuild_paths(obj):
#
# Site/rack/device assignment
# Location/rack/device assignment
#
@receiver(post_save, sender=RackGroup)
def handle_rackgroup_site_change(instance, created, **kwargs):
@receiver(post_save, sender=Location)
def handle_location_site_change(instance, created, **kwargs):
"""
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
Update child objects if Site assignment has changed. We intentionally recurse through each child
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
"""
if not created:
for rackgroup in instance.get_children():
rackgroup.site = instance.site
rackgroup.save()
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
rack.site = instance.site
rack.save()
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
powerpanel.site = instance.site
powerpanel.save()
instance.get_descendants().update(site=instance.site)
locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
@receiver(post_save, sender=Rack)
def handle_rack_site_change(instance, created, **kwargs):
"""
Update child Devices if Site assignment has changed.
Update child Devices if Site or Location assignment has changed.
"""
if not created:
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
device.site = instance.site
device.save()
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
#

View File

@@ -1,11 +1,12 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.conf import settings
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
PowerOutlet, PowerPort, RearPort, VirtualChassis,
)
from tenancy.tables import COL_TENANT
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn,
@@ -109,12 +110,13 @@ class DeviceTable(BaseTable):
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
location = tables.Column(
linkify=True
)
rack = tables.Column(
linkify=True
)
@@ -127,11 +129,18 @@ class DeviceTable(BaseTable):
verbose_name='Type',
text=lambda record: record.device_type.display_name
)
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
if settings.PREFER_IPV4:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address'
)
else:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
primary_ip4 = tables.Column(
linkify=True,
verbose_name='IPv4 Address'
@@ -162,11 +171,11 @@ class DeviceTable(BaseTable):
model = Device
fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'tags',
'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip',
)
@@ -175,9 +184,7 @@ class DeviceImportTable(BaseTable):
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
@@ -249,10 +256,10 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
class DeviceConsolePortTable(ConsolePortTable):
@@ -269,10 +276,10 @@ class DeviceConsolePortTable(ConsolePortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', 'connection',
'tags', 'actions'
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
@@ -286,10 +293,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
class DeviceConsoleServerPortTable(ConsoleServerPortTable):
@@ -307,10 +314,10 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags',
'actions',
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
@@ -437,6 +444,10 @@ class DeviceInterfaceTable(InterfaceTable):
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
parent = tables.Column(
linkify=True,
verbose_name='Parent'
)
lag = tables.Column(
linkify=True,
verbose_name='LAG'
@@ -450,13 +461,13 @@ class DeviceInterfaceTable(InterfaceTable):
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
'tagged_vlans', 'actions',
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
'connection', 'actions',
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection', 'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else '',

View File

@@ -33,8 +33,8 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
#

View File

@@ -1,18 +1,18 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackGroup, RackReservation, RackRole
from tenancy.tables import COL_TENANT
from dcim.models import Rack, Location, RackReservation, RackRole
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
ToggleColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
TagColumn, ToggleColumn, UtilizationColumn,
)
from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH
from .template_code import LOCATION_ELEVATIONS
__all__ = (
'RackTable',
'RackDetailTable',
'RackGroupTable',
'LocationTable',
'RackReservationTable',
'RackRoleTable',
)
@@ -22,13 +22,9 @@ __all__ = (
# Rack groups
#
class RackGroupTable(BaseTable):
class LocationTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
name = MPTTColumn()
site = tables.Column(
linkify=True
)
@@ -36,12 +32,12 @@ class RackGroupTable(BaseTable):
verbose_name='Racks'
)
actions = ButtonsColumn(
model=RackGroup,
prepend_template=RACKGROUP_ELEVATIONS
model=Location,
prepend_template=LOCATION_ELEVATIONS
)
class Meta(BaseTable.Meta):
model = RackGroup
model = Location
fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
@@ -79,9 +75,7 @@ class RackTable(BaseTable):
site = tables.Column(
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = ColoredLabelColumn()
u_height = tables.TemplateColumn(
@@ -104,13 +98,10 @@ class RackDetailTable(RackTable):
url_params={'rack_id': 'pk'},
verbose_name='Devices'
)
get_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
get_utilization = UtilizationColumn(
verbose_name='Space'
)
get_power_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
get_power_utilization = UtilizationColumn(
orderable=False,
verbose_name='Power'
)
@@ -143,9 +134,7 @@ class RackReservationTable(BaseTable):
accessor=Accessor('rack__site'),
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
rack = tables.Column(
linkify=True
)

View File

@@ -1,13 +1,13 @@
import django_tables2 as tables
from dcim.models import Region, Site
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .template_code import MPTT_LINK
from dcim.models import Region, Site, SiteGroup
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn
__all__ = (
'RegionTable',
'SiteTable',
'SiteGroupTable',
)
@@ -17,11 +17,7 @@ __all__ = (
class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
name = MPTTColumn()
site_count = tables.Column(
verbose_name='Sites'
)
@@ -33,6 +29,24 @@ class RegionTable(BaseTable):
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
#
# Site groups
#
class SiteGroupTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn()
site_count = tables.Column(
verbose_name='Sites'
)
actions = ButtonsColumn(SiteGroup)
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
#
# Sites
#
@@ -46,9 +60,10 @@ class SiteTable(BaseTable):
region = tables.Column(
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = TagColumn(
url_name='dcim:site_list'
)
@@ -56,8 +71,8 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')

View File

@@ -1,6 +1,6 @@
CABLETERMINATION = """
{% if value %}
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
@@ -56,13 +56,6 @@ INTERFACE_TAGGED_VLANS = """
{% endif %}
"""
MPTT_LINK = """
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
@@ -71,22 +64,17 @@ POWERFEED_CABLE = """
"""
POWERFEED_CABLETERMINATION = """
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
"""
RACKGROUP_ELEVATIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
LOCATION_ELEVATIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>
"""
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph value %}
"""
#
# Device component buttons
#

View File

@@ -4,12 +4,7 @@ from rest_framework import status
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from dcim.models import *
from ipam.models import VLAN
from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterType
@@ -64,7 +59,7 @@ class Mixins:
class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region
brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url']
brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [
{
'name': 'Region 4',
@@ -93,7 +88,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
class SiteTest(APIViewTestCases.APIViewTestCase):
model = Site
brief_fields = ['id', 'name', 'slug', 'url']
brief_fields = ['display', 'id', 'name', 'slug', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -102,14 +97,19 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
def setUpTestData(cls):
regions = (
Region.objects.create(name='Test Region 1', slug='test-region-1'),
Region.objects.create(name='Test Region 2', slug='test-region-2'),
Region.objects.create(name='Region 1', slug='region-1'),
Region.objects.create(name='Region 2', slug='region-2'),
)
groups = (
SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'),
SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
)
sites = (
Site(region=regions[0], name='Site 1', slug='site-1'),
Site(region=regions[0], name='Site 2', slug='site-2'),
Site(region=regions[0], name='Site 3', slug='site-3'),
Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'),
Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
@@ -118,26 +118,29 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
'name': 'Site 4',
'slug': 'site-4',
'region': regions[1].pk,
'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE,
},
{
'name': 'Site 5',
'slug': 'site-5',
'region': regions[1].pk,
'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE,
},
{
'name': 'Site 6',
'slug': 'site-6',
'region': regions[1].pk,
'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE,
},
]
class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
class LocationTest(APIViewTestCases.APIViewTestCase):
model = Location
brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -151,40 +154,40 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase):
)
Site.objects.bulk_create(sites)
parent_rack_groups = (
RackGroup.objects.create(site=sites[0], name='Parent Rack Group 1', slug='parent-rack-group-1'),
RackGroup.objects.create(site=sites[1], name='Parent Rack Group 2', slug='parent-rack-group-2'),
parent_locations = (
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
)
RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1', parent=parent_rack_groups[0])
RackGroup.objects.create(site=sites[0], name='Rack Group 2', slug='rack-group-2', parent=parent_rack_groups[0])
RackGroup.objects.create(site=sites[0], name='Rack Group 3', slug='rack-group-3', parent=parent_rack_groups[0])
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
cls.create_data = [
{
'name': 'Test Rack Group 4',
'slug': 'test-rack-group-4',
'name': 'Test Location 4',
'slug': 'test-location-4',
'site': sites[1].pk,
'parent': parent_rack_groups[1].pk,
'parent': parent_locations[1].pk,
},
{
'name': 'Test Rack Group 5',
'slug': 'test-rack-group-5',
'name': 'Test Location 5',
'slug': 'test-location-5',
'site': sites[1].pk,
'parent': parent_rack_groups[1].pk,
'parent': parent_locations[1].pk,
},
{
'name': 'Test Rack Group 6',
'slug': 'test-rack-group-6',
'name': 'Test Location 6',
'slug': 'test-location-6',
'site': sites[1].pk,
'parent': parent_rack_groups[1].pk,
'parent': parent_locations[1].pk,
},
]
class RackRoleTest(APIViewTestCases.APIViewTestCase):
model = RackRole
brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url']
create_data = [
{
'name': 'Rack Role 4',
@@ -219,7 +222,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack
brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -233,9 +236,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
)
Site.objects.bulk_create(sites)
rack_groups = (
RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1'),
RackGroup.objects.create(site=sites[1], name='Rack Group 2', slug='rack-group-2'),
locations = (
Location.objects.create(site=sites[0], name='Location 1', slug='location-1'),
Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
)
rack_roles = (
@@ -245,9 +248,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
RackRole.objects.bulk_create(rack_roles)
racks = (
Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'),
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'),
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'),
)
Rack.objects.bulk_create(racks)
@@ -255,19 +258,19 @@ class RackTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Test Rack 4',
'site': sites[1].pk,
'group': rack_groups[1].pk,
'location': locations[1].pk,
'role': rack_roles[1].pk,
},
{
'name': 'Test Rack 5',
'site': sites[1].pk,
'group': rack_groups[1].pk,
'location': locations[1].pk,
'role': rack_roles[1].pk,
},
{
'name': 'Test Rack 6',
'site': sites[1].pk,
'group': rack_groups[1].pk,
'location': locations[1].pk,
'role': rack_roles[1].pk,
},
]
@@ -307,7 +310,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation
brief_fields = ['id', 'units', 'url', 'user']
brief_fields = ['display', 'id', 'units', 'url', 'user']
bulk_update_data = {
'description': 'New description',
}
@@ -358,7 +361,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
model = Manufacturer
brief_fields = ['devicetype_count', 'id', 'name', 'slug', 'url']
brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Manufacturer 4',
@@ -390,7 +393,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
model = DeviceType
brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@@ -432,7 +435,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -469,7 +472,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsoleServerPortTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -506,7 +509,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerPortTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -543,7 +546,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerOutletTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -580,7 +583,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
model = InterfaceTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -620,7 +623,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = FrontPortTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -691,7 +694,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = RearPortTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -731,7 +734,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -771,7 +774,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
model = DeviceRole
brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [
{
'name': 'Device Role 4',
@@ -806,7 +809,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
class PlatformTest(APIViewTestCases.APIViewTestCase):
model = Platform
brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
create_data = [
{
'name': 'Platform 4',
@@ -838,7 +841,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
class DeviceTest(APIViewTestCases.APIViewTestCase):
model = Device
brief_fields = ['display_name', 'id', 'name', 'url']
brief_fields = ['display', 'display_name', 'id', 'name', 'url']
bulk_update_data = {
'status': 'failed',
}
@@ -979,7 +982,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1018,7 +1021,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1057,7 +1060,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1096,7 +1099,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1135,7 +1138,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1193,7 +1196,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1251,7 +1254,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort
brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1293,7 +1296,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
brief_fields = ['device', 'id', 'name', 'url']
brief_fields = ['device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1356,7 +1359,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
model = InventoryItem
brief_fields = ['_depth', 'device', 'id', 'name', 'url']
brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1394,7 +1397,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable
brief_fields = ['id', 'label', 'url']
brief_fields = ['display', 'id', 'label', 'url']
bulk_update_data = {
'length': 100,
'length_unit': 'm',
@@ -1579,7 +1582,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
class PowerPanelTest(APIViewTestCases.APIViewTestCase):
model = PowerPanel
brief_fields = ['id', 'name', 'powerfeed_count', 'url']
brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url']
@classmethod
def setUpTestData(cls):
@@ -1588,17 +1591,17 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
Site.objects.create(name='Site 2', slug='site-2'),
)
rack_groups = (
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]),
RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]),
RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]),
locations = (
Location.objects.create(name='Location 1', slug='location-1', site=sites[0]),
Location.objects.create(name='Location 2', slug='location-2', site=sites[0]),
Location.objects.create(name='Location 3', slug='location-3', site=sites[0]),
Location.objects.create(name='Location 4', slug='location-3', site=sites[1]),
)
power_panels = (
PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'),
PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'),
PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
PowerPanel(site=sites[0], location=locations[1], name='Power Panel 2'),
PowerPanel(site=sites[0], location=locations[2], name='Power Panel 3'),
)
PowerPanel.objects.bulk_create(power_panels)
@@ -1606,29 +1609,29 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Power Panel 4',
'site': sites[0].pk,
'rack_group': rack_groups[0].pk,
'location': locations[0].pk,
},
{
'name': 'Power Panel 5',
'site': sites[0].pk,
'rack_group': rack_groups[1].pk,
'location': locations[1].pk,
},
{
'name': 'Power Panel 6',
'site': sites[0].pk,
'rack_group': rack_groups[2].pk,
'location': locations[2].pk,
},
]
cls.bulk_update_data = {
'site': sites[1].pk,
'rack_group': rack_groups[3].pk
'location': locations[3].pk
}
class PowerFeedTest(APIViewTestCases.APIViewTestCase):
model = PowerFeed
brief_fields = ['_occupied', 'cable', 'id', 'name', 'url']
brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@@ -1636,20 +1639,20 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
rackgroup = RackGroup.objects.create(site=site, name='Rack Group 1', slug='rack-group-1')
location = Location.objects.create(site=site, name='Location 1', slug='location-1')
rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
racks = (
Rack(site=site, group=rackgroup, role=rackrole, name='Rack 1'),
Rack(site=site, group=rackgroup, role=rackrole, name='Rack 2'),
Rack(site=site, group=rackgroup, role=rackrole, name='Rack 3'),
Rack(site=site, group=rackgroup, role=rackrole, name='Rack 4'),
Rack(site=site, location=location, role=rackrole, name='Rack 1'),
Rack(site=site, location=location, role=rackrole, name='Rack 2'),
Rack(site=site, location=location, role=rackrole, name='Rack 3'),
Rack(site=site, location=location, role=rackrole, name='Rack 4'),
)
Rack.objects.bulk_create(racks)
power_panels = (
PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 1'),
PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 2'),
PowerPanel(site=site, location=location, name='Power Panel 1'),
PowerPanel(site=site, location=location, name='Power Panel 2'),
)
PowerPanel.objects.bulk_create(power_panels)

View File

@@ -3,13 +3,7 @@ from django.test import TestCase
from dcim.choices import *
from dcim.filters import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerPortTemplate, PowerOutlet,
PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from dcim.models import *
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterType
@@ -65,6 +59,56 @@ class RegionTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class SiteGroupTestCase(TestCase):
queryset = SiteGroup.objects.all()
filterset = SiteGroupFilterSet
@classmethod
def setUpTestData(cls):
sitegroups = (
SiteGroup(name='Site Group 1', slug='site-group-1', description='A'),
SiteGroup(name='Site Group 2', slug='site-group-2', description='B'),
SiteGroup(name='Site Group 3', slug='site-group-3', description='C'),
)
for sitegroup in sitegroups:
sitegroup.save()
child_sitegroups = (
SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]),
SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]),
SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]),
SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]),
SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]),
SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]),
)
for sitegroup in child_sitegroups:
sitegroup.save()
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Site Group 1', 'Site Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['site-group-1', 'site-group-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2]
params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class SiteTestCase(TestCase):
queryset = Site.objects.all()
filterset = SiteFilterSet
@@ -80,6 +124,14 @@ class SiteTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -96,9 +148,9 @@ class SiteTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
sites = (
Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
)
Site.objects.bulk_create(sites)
@@ -153,6 +205,13 @@ class SiteTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
groups = SiteGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -168,9 +227,9 @@ class SiteTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackGroupTestCase(TestCase):
queryset = RackGroup.objects.all()
filterset = RackGroupFilterSet
class LocationTestCase(TestCase):
queryset = Location.objects.all()
filterset = LocationFilterSet
@classmethod
def setUpTestData(cls):
@@ -183,39 +242,47 @@ class RackGroupTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
parent_rack_groups = (
RackGroup(name='Parent Rack Group 1', slug='parent-rack-group-1', site=sites[0]),
RackGroup(name='Parent Rack Group 2', slug='parent-rack-group-2', site=sites[1]),
RackGroup(name='Parent Rack Group 3', slug='parent-rack-group-3', site=sites[2]),
parent_locations = (
Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]),
Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]),
Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]),
)
for rackgroup in parent_rack_groups:
rackgroup.save()
for location in parent_locations:
location.save()
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0], description='A'),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1], description='B'),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2], description='C'),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Rack Group 1', 'Rack Group 2']}
params = {'name': ['Location 1', 'Location 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['rack-group-1', 'rack-group-2']}
params = {'slug': ['location-1', 'location-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
@@ -229,6 +296,13 @@ class RackGroupTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -237,7 +311,7 @@ class RackGroupTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_parent(self):
parent_groups = RackGroup.objects.filter(name__startswith='Parent')[:2]
parent_groups = Location.objects.filter(name__startswith='Parent')[:2]
params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
@@ -290,20 +364,28 @@ class RackTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -328,9 +410,9 @@ class RackTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
)
Rack.objects.bulk_create(racks)
@@ -388,6 +470,13 @@ class RackTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -395,11 +484,11 @@ class RackTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_group(self):
groups = RackGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [groups[0].slug, groups[1].slug]}
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
@@ -448,18 +537,18 @@ class RackReservationTestCase(TestCase):
)
Site.objects.bulk_create(sites)
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
Rack(name='Rack 2', site=sites[1], group=rack_groups[1]),
Rack(name='Rack 3', site=sites[2], group=rack_groups[2]),
Rack(name='Rack 1', site=sites[0], location=locations[0]),
Rack(name='Rack 2', site=sites[1], location=locations[1]),
Rack(name='Rack 3', site=sites[2], location=locations[2]),
)
Rack.objects.bulk_create(racks)
@@ -503,11 +592,11 @@ class RackReservationTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_group(self):
groups = RackGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [groups[0].slug, groups[1].slug]}
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_user(self):
@@ -1161,25 +1250,33 @@ class DeviceTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
Rack(name='Rack 2', site=sites[1], group=rack_groups[1]),
Rack(name='Rack 3', site=sites[2], group=rack_groups[2]),
Rack(name='Rack 1', site=sites[0], location=locations[0]),
Rack(name='Rack 2', site=sites[1], location=locations[1]),
Rack(name='Rack 3', site=sites[2], location=locations[2]),
)
Rack.objects.bulk_create(racks)
@@ -1207,9 +1304,9 @@ class DeviceTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
devices = (
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
)
Device.objects.bulk_create(devices)
@@ -1324,6 +1421,13 @@ class DeviceTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1331,9 +1435,9 @@ class DeviceTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rackgroup(self):
rack_groups = RackGroup.objects.all()[:2]
params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]}
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
@@ -1463,10 +1567,19 @@ class ConsolePortTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1524,6 +1637,13 @@ class ConsolePortTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1559,10 +1679,19 @@ class ConsoleServerPortTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1620,6 +1749,13 @@ class ConsoleServerPortTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1655,10 +1791,19 @@ class PowerPortTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1724,6 +1869,13 @@ class PowerPortTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1759,10 +1911,19 @@ class PowerOutletTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1825,6 +1986,13 @@ class PowerOutletTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1860,10 +2028,19 @@ class InterfaceTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1931,6 +2108,34 @@ class InterfaceTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
# Create child interfaces
parent_interface = Interface.objects.first()
child_interfaces = (
Interface(device=parent_interface.device, name='Child 1', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
Interface(device=parent_interface.device, name='Child 2', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
Interface(device=parent_interface.device, name='Child 3', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
)
Interface.objects.bulk_create(child_interfaces)
params = {'parent_id': [parent_interface.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_lag(self):
# Create LAG members
device = Device.objects.first()
lag_interface = Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG)
lag_interface.save()
lag_members = (
Interface(device=device, name='Member 1', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Member 2', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Member 3', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(lag_members)
params = {'lag_id': [lag_interface.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1938,6 +2143,13 @@ class InterfaceTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -1987,10 +2199,19 @@ class FrontPortTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2054,6 +2275,13 @@ class FrontPortTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2089,10 +2317,19 @@ class RearPortTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2150,6 +2387,13 @@ class RearPortTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2185,10 +2429,19 @@ class DeviceBayTestCase(TestCase):
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2228,6 +2481,13 @@ class DeviceBayTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2268,10 +2528,18 @@ class InventoryItemTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
@@ -2328,6 +2596,13 @@ class InventoryItemTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2381,10 +2656,18 @@ class VirtualChassisTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
@@ -2435,6 +2718,13 @@ class VirtualChassisTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2582,25 +2872,33 @@ class PowerPanelTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
power_panels = (
PowerPanel(name='Power Panel 1', site=sites[0], rack_group=rack_groups[0]),
PowerPanel(name='Power Panel 2', site=sites[1], rack_group=rack_groups[1]),
PowerPanel(name='Power Panel 3', site=sites[2], rack_group=rack_groups[2]),
PowerPanel(name='Power Panel 1', site=sites[0], location=locations[0]),
PowerPanel(name='Power Panel 2', site=sites[1], location=locations[1]),
PowerPanel(name='Power Panel 3', site=sites[2], location=locations[2]),
)
PowerPanel.objects.bulk_create(power_panels)
@@ -2619,6 +2917,13 @@ class PowerPanelTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -2626,9 +2931,9 @@ class PowerPanelTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack_group(self):
rack_groups = RackGroup.objects.all()[:2]
params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]}
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2647,10 +2952,18 @@ class PowerFeedTestCase(TestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = (
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
)
Site.objects.bulk_create(sites)
@@ -2731,6 +3044,13 @@ class PowerFeedTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -7,39 +7,66 @@ from dcim.models import *
from tenancy.models import Tenant
class RackGroupTestCase(TestCase):
class LocationTestCase(TestCase):
def test_change_rackgroup_site(self):
def test_change_location_site(self):
"""
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology:
Site A
- RackGroup A1
- RackGroup A2
- Location A1
- Location A2
- Rack 2
- Device 2
- Rack 1
- Device 1
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
rackgroup_a1.save()
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
rackgroup_a2.save()
location_a1 = Location(site=site_a, name='Location A1', slug='location-a1')
location_a1.save()
location_a2 = Location(site=site_a, parent=location_a1, name='Location A2', slug='location-a2')
location_a2.save()
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1')
rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2')
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
device1 = Device.objects.create(
site=site_a,
location=location_a1,
name='Device 1',
device_type=device_type,
device_role=device_role
)
device2 = Device.objects.create(
site=site_a,
location=location_a2,
name='Device 2',
device_type=device_type,
device_role=device_role
)
# Move RackGroup A1 to Site B
rackgroup_a1.site = site_b
rackgroup_a1.save()
powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1')
# Check that all objects within RackGroup A1 now belong to Site B
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
# Move Location A1 to Site B
location_a1.site = site_b
location_a1.save()
# Check that all objects within Location A1 now belong to Site B
self.assertEqual(Location.objects.get(pk=location_a1.pk).site, site_b)
self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b)
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
@@ -55,12 +82,12 @@ class RackTestCase(TestCase):
name='TestSite2',
slug='test-site-2'
)
self.group1 = RackGroup.objects.create(
self.location1 = Location.objects.create(
name='TestGroup1',
slug='test-group-1',
site=self.site1
)
self.group2 = RackGroup.objects.create(
self.location2 = Location.objects.create(
name='TestGroup2',
slug='test-group-2',
site=self.site2
@@ -69,7 +96,7 @@ class RackTestCase(TestCase):
name='TestRack1',
facility_id='A101',
site=self.site1,
group=self.group1,
location=self.location1,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
@@ -134,19 +161,19 @@ class RackTestCase(TestCase):
with self.assertRaises(ValidationError):
rack1.clean()
def test_rack_group_site(self):
def test_location_site(self):
rack_invalid_group = Rack(
rack_invalid_location = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42,
group=self.group2
location=self.location2
)
rack_invalid_group.save()
rack_invalid_location.save()
with self.assertRaises(ValidationError):
rack_invalid_group.clean()
rack_invalid_location.clean()
def test_mount_single_device(self):

View File

@@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Region 6,region-6,Sixth region",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = SiteGroup
@classmethod
def setUpTestData(cls):
# Create three SiteGroups
sitegroups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for sitegroup in sitegroups:
sitegroup.save()
cls.form_data = {
'name': 'Site Group X',
'slug': 'site-group-x',
'parent': sitegroups[2].pk,
'description': 'A new site group',
}
cls.csv_data = (
"name,slug,description",
"Site Group 4,site-group-4,Fourth site group",
"Site Group 5,site-group-5,Fifth site group",
"Site Group 6,site-group-6,Sixth site group",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Site
@@ -71,10 +109,17 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
)
for group in groups:
group.save()
Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[0]),
Site(name='Site 3', slug='site-3', region=regions[0]),
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]),
Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@@ -84,6 +129,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'region': regions[1].pk,
'group': groups[1].pk,
'tenant': None,
'facility': 'Facility X',
'asn': 65001,
@@ -110,6 +156,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'status': SiteStatusChoices.STATUS_PLANNED,
'region': regions[1].pk,
'group': groups[1].pk,
'tenant': None,
'asn': 65009,
'time_zone': pytz.timezone('US/Eastern'),
@@ -117,8 +164,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackGroup
class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Location
@classmethod
def setUpTestData(cls):
@@ -126,28 +173,32 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site = Site(name='Site 1', slug='site-1')
site.save()
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
locations = (
Location(name='Location 1', slug='location-1', site=site),
Location(name='Location 2', slug='location-2', site=site),
Location(name='Location 3', slug='location-3', site=site),
)
for rackgroup in rack_groups:
rackgroup.save()
for location in locations:
location.save()
cls.form_data = {
'name': 'Rack Group X',
'slug': 'rack-group-x',
'name': 'Location X',
'slug': 'location-x',
'site': site.pk,
'description': 'A new rack group',
'description': 'A new location',
}
cls.csv_data = (
"site,name,slug,description",
"Site 1,Rack Group 4,rack-group-4,Fourth rack group",
"Site 1,Rack Group 5,rack-group-5,Fifth rack group",
"Site 1,Rack Group 6,rack-group-6,Sixth rack group",
"Site 1,Location 4,location-4,Fourth location",
"Site 1,Location 5,location-5,Fifth location",
"Site 1,Location 6,location-6,Sixth location",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackRole
@@ -175,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Rack Role 6,rack-role-6,0000ff",
)
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
}
class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = RackReservation
@@ -187,10 +243,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site = Site.objects.create(name='Site 1', slug='site-1')
rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site)
rack_group.save()
location = Location(name='Location 1', slug='location-1', site=site)
location.save()
rack = Rack(name='Rack 1', site=site, group=rack_group)
rack = Rack(name='Rack 1', site=site, location=location)
rack.save()
RackReservation.objects.bulk_create([
@@ -211,10 +267,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'site,rack_group,rack,units,description',
'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1',
'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2',
'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3',
'site,location,rack,units,description',
'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1',
'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2',
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3',
)
cls.bulk_edit_data = {
@@ -236,12 +292,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Site.objects.bulk_create(sites)
rackgroups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1])
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1])
)
for rackgroup in rackgroups:
rackgroup.save()
for location in locations:
location.save()
rackroles = (
RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -261,7 +317,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Rack X',
'facility_id': 'Facility X',
'site': sites[1].pk,
'group': rackgroups[1].pk,
'location': locations[1].pk,
'tenant': None,
'status': RackStatusChoices.STATUS_PLANNED,
'role': rackroles[1].pk,
@@ -279,15 +335,15 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,group,name,width,u_height",
"site,location,name,width,u_height",
"Site 1,,Rack 4,19,42",
"Site 1,Rack Group 1,Rack 5,19,42",
"Site 2,Rack Group 2,Rack 6,19,42",
"Site 1,Location 1,Rack 5,19,42",
"Site 2,Location 2,Rack 6,19,42",
)
cls.bulk_edit_data = {
'site': sites[1].pk,
'group': rackgroups[1].pk,
'location': locations[1].pk,
'tenant': None,
'status': RackStatusChoices.STATUS_DEPRECATED,
'role': rackroles[1].pk,
@@ -336,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Manufacturer 6,manufacturer-6,Sixth manufacturer",
)
cls.bulk_edit_data = {
'description': 'New description',
}
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by absence of bulk import view for DeviceTypes
@@ -885,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Device Role 6,device-role-6,0000ff",
)
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
}
class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Platform
@@ -916,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Platform 6,platform-6,Sixth platform",
)
cls.bulk_edit_data = {
'napalm_driver': 'ios',
'description': 'New description',
}
class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Device
@@ -929,11 +999,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Site.objects.bulk_create(sites)
rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
rack_group.save()
location = Location(site=sites[0], name='Location 1', slug='location-1')
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], group=rack_group),
Rack(name='Rack 1', site=sites[0], location=location),
Rack(name='Rack 2', site=sites[1]),
)
Rack.objects.bulk_create(racks)
@@ -991,10 +1061,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front",
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
)
cls.bulk_edit_data = {
@@ -1771,38 +1841,38 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Site.objects.bulk_create(sites)
rackgroups = (
RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
)
for rackgroup in rackgroups:
rackgroup.save()
for location in locations:
location.save()
PowerPanel.objects.bulk_create((
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'),
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'),
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'site': sites[1].pk,
'rack_group': rackgroups[1].pk,
'location': locations[1].pk,
'name': 'Power Panel X',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"site,rack_group,name",
"Site 1,Rack Group 1,Power Panel 4",
"Site 1,Rack Group 1,Power Panel 5",
"Site 1,Rack Group 1,Power Panel 6",
"site,location,name",
"Site 1,Location 1,Power Panel 4",
"Site 1,Location 1,Power Panel 5",
"Site 1,Location 1,Power Panel 6",
)
cls.bulk_edit_data = {
'site': sites[1].pk,
'rack_group': rackgroups[1].pk,
'location': locations[1].pk,
}

View File

@@ -1,13 +1,9 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
from .models import *
app_name = 'dcim'
urlpatterns = [
@@ -16,11 +12,22 @@ urlpatterns = [
path('regions/', views.RegionListView.as_view(), name='region_list'),
path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Site groups
path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'),
path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'),
path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
path('site-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
# Sites
path('sites/', views.SiteListView.as_view(), name='site_list'),
path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
@@ -31,21 +38,24 @@ urlpatterns = [
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path('rack-groups/<int:pk>/delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Locations
path('locations/', views.LocationListView.as_view(), name='location_list'),
path('locations/add/', views.LocationEditView.as_view(), name='location_add'),
path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
@@ -61,6 +71,7 @@ urlpatterns = [
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
@@ -73,12 +84,14 @@ urlpatterns = [
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
@@ -94,6 +107,7 @@ urlpatterns = [
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
# Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
@@ -163,6 +177,7 @@ urlpatterns = [
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
@@ -172,6 +187,7 @@ urlpatterns = [
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
@@ -198,6 +214,7 @@ urlpatterns = [
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
@@ -353,6 +370,7 @@ urlpatterns = [
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
# Console/power/interface connections (read-only)
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
@@ -369,6 +387,7 @@ urlpatterns = [
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
@@ -382,6 +401,7 @@ urlpatterns = [
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
@@ -394,6 +414,7 @@ urlpatterns = [
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
]

View File

@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View
from circuits.models import Circuit
from extras.views import ObjectChangeLogView, ObjectConfigContextView
from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic
@@ -30,8 +30,8 @@ from .models import (
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
SiteGroup, VirtualChassis,
)
@@ -126,6 +126,19 @@ class RegionBulkImportView(generic.BulkImportView):
table = tables.RegionTable
class RegionBulkEditView(generic.BulkEditView):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
'region',
'site_count',
cumulative=True
)
filterset = filters.RegionFilterSet
table = tables.RegionTable
form = forms.RegionBulkEditForm
class RegionBulkDeleteView(generic.BulkDeleteView):
queryset = Region.objects.add_related_count(
Region.objects.all(),
@@ -138,6 +151,63 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable
#
# Site groups
#
class SiteGroupListView(generic.ObjectListView):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
'group',
'site_count',
cumulative=True
)
filterset = filters.SiteGroupFilterSet
filterset_form = forms.SiteGroupFilterForm
table = tables.SiteGroupTable
class SiteGroupEditView(generic.ObjectEditView):
queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupForm
class SiteGroupDeleteView(generic.ObjectDeleteView):
queryset = SiteGroup.objects.all()
class SiteGroupBulkImportView(generic.BulkImportView):
queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupCSVForm
table = tables.SiteGroupTable
class SiteGroupBulkEditView(generic.BulkEditView):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
'group',
'site_count',
cumulative=True
)
filterset = filters.SiteGroupFilterSet
table = tables.SiteGroupTable
form = forms.SiteGroupBulkEditForm
class SiteGroupBulkDeleteView(generic.BulkDeleteView):
queryset = SiteGroup.objects.add_related_count(
SiteGroup.objects.all(),
Site,
'group',
'site_count',
cumulative=True
)
filterset = filters.SiteGroupFilterSet
table = tables.SiteGroupTable
#
# Sites
#
@@ -161,17 +231,17 @@ class SiteView(generic.ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
}
rack_groups = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
locations = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'group',
'location',
'rack_count',
cumulative=True
).restrict(request.user, 'view').filter(site=instance)
return {
'stats': stats,
'rack_groups': rack_groups,
'locations': locations,
}
@@ -207,44 +277,57 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
# Rack groups
#
class RackGroupListView(generic.ObjectListView):
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
class LocationListView(generic.ObjectListView):
queryset = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'group',
'location',
'rack_count',
cumulative=True
)
filterset = filters.RackGroupFilterSet
filterset_form = forms.RackGroupFilterForm
table = tables.RackGroupTable
filterset = filters.LocationFilterSet
filterset_form = forms.LocationFilterForm
table = tables.LocationTable
class RackGroupEditView(generic.ObjectEditView):
queryset = RackGroup.objects.all()
model_form = forms.RackGroupForm
class LocationEditView(generic.ObjectEditView):
queryset = Location.objects.all()
model_form = forms.LocationForm
class RackGroupDeleteView(generic.ObjectDeleteView):
queryset = RackGroup.objects.all()
class LocationDeleteView(generic.ObjectDeleteView):
queryset = Location.objects.all()
class RackGroupBulkImportView(generic.BulkImportView):
queryset = RackGroup.objects.all()
model_form = forms.RackGroupCSVForm
table = tables.RackGroupTable
class LocationBulkImportView(generic.BulkImportView):
queryset = Location.objects.all()
model_form = forms.LocationCSVForm
table = tables.LocationTable
class RackGroupBulkDeleteView(generic.BulkDeleteView):
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
class LocationBulkEditView(generic.BulkEditView):
queryset = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'group',
'location',
'rack_count',
cumulative=True
).prefetch_related('site')
filterset = filters.RackGroupFilterSet
table = tables.RackGroupTable
filterset = filters.LocationFilterSet
table = tables.LocationTable
form = forms.LocationBulkEditForm
class LocationBulkDeleteView(generic.BulkDeleteView):
queryset = Location.objects.add_related_count(
Location.objects.all(),
Rack,
'location',
'rack_count',
cumulative=True
).prefetch_related('site')
filterset = filters.LocationFilterSet
table = tables.LocationTable
#
@@ -273,6 +356,15 @@ class RackRoleBulkImportView(generic.BulkImportView):
table = tables.RackRoleTable
class RackRoleBulkEditView(generic.BulkEditView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filters.RackRoleFilterSet
table = tables.RackRoleTable
form = forms.RackRoleBulkEditForm
class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
@@ -286,7 +378,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
class RackListView(generic.ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
'site', 'location', 'tenant', 'role', 'devices__device_type'
).annotate(
device_count=count_related(Device, 'rack')
)
@@ -338,7 +430,7 @@ class RackElevationListView(generic.ObjectListView):
class RackView(generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance):
# Get 0U and child devices located within the rack
@@ -349,10 +441,10 @@ class RackView(generic.ObjectView):
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.group:
peer_racks = peer_racks.filter(group=instance.group)
if instance.location:
peer_racks = peer_racks.filter(location=instance.location)
else:
peer_racks = peer_racks.filter(group__isnull=True)
peer_racks = peer_racks.filter(location__isnull=True)
next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
@@ -390,14 +482,14 @@ class RackBulkImportView(generic.BulkImportView):
class RackBulkEditView(generic.BulkEditView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
form = forms.RackBulkEditForm
class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
@@ -490,6 +582,15 @@ class ManufacturerBulkImportView(generic.BulkImportView):
table = tables.ManufacturerTable
class ManufacturerBulkEditView(generic.BulkEditView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer')
)
filterset = filters.ManufacturerFilterSet
table = tables.ManufacturerTable
form = forms.ManufacturerBulkEditForm
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer')
@@ -931,6 +1032,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
table = tables.DeviceRoleTable
class DeviceRoleBulkEditView(generic.BulkEditView):
queryset = DeviceRole.objects.all()
filterset = filters.DeviceRoleFilterSet
table = tables.DeviceRoleTable
form = forms.DeviceRoleBulkEditForm
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
@@ -963,6 +1071,13 @@ class PlatformBulkImportView(generic.BulkImportView):
table = tables.PlatformTable
class PlatformBulkEditView(generic.BulkEditView):
queryset = Platform.objects.all()
filterset = filters.PlatformFilterSet
table = tables.PlatformTable
form = forms.PlatformBulkEditForm
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
table = tables.PlatformTable
@@ -982,7 +1097,7 @@ class DeviceListView(generic.ObjectListView):
class DeviceView(generic.ObjectView):
queryset = Device.objects.prefetch_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
)
def get_extra_context(self, request, instance):
@@ -1268,6 +1383,10 @@ class DeviceChangeLogView(ObjectChangeLogView):
base_template = 'dcim/device/base.html'
class DeviceJournalView(ObjectJournalView):
base_template = 'dcim/device/base.html'
class DeviceEditView(generic.ObjectEditView):
queryset = Device.objects.all()
model_form = forms.DeviceForm
@@ -2178,13 +2297,15 @@ class CableCreateView(generic.ObjectEditView):
initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
termination_a_site = getattr(obj.termination_a.parent, 'site', None)
termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
if termination_a_site and 'termination_b_region' not in initial_data:
initial_data['termination_b_region'] = termination_a_site.region
if termination_a_site and 'termination_b_site_group' not in initial_data:
initial_data['termination_b_site_group'] = termination_a_site.group
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = termination_a_site
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
form = self.model_form(instance=obj, initial=initial_data)
@@ -2560,7 +2681,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
class PowerPanelListView(generic.ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
'site', 'location'
).annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
@@ -2570,7 +2691,7 @@ class PowerPanelListView(generic.ObjectListView):
class PowerPanelView(generic.ObjectView):
queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
queryset = PowerPanel.objects.prefetch_related('site', 'location')
def get_extra_context(self, request, instance):
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
@@ -2601,7 +2722,7 @@ class PowerPanelBulkImportView(generic.BulkImportView):
class PowerPanelBulkEditView(generic.BulkEditView):
queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
queryset = PowerPanel.objects.prefetch_related('site', 'location')
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable
form = forms.PowerPanelBulkEditForm
@@ -2609,7 +2730,7 @@ class PowerPanelBulkEditView(generic.BulkEditView):
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
'site', 'location'
).annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)

View File

@@ -132,15 +132,15 @@ class CustomLinkForm(forms.ModelForm):
model = CustomLink
exclude = []
widgets = {
'text': forms.Textarea,
'url': forms.Textarea,
'link_text': forms.Textarea,
'link_url': forms.Textarea,
}
help_texts = {
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
'first in a list.',
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
def __init__(self, *args, **kwargs):
@@ -158,7 +158,7 @@ class CustomLinkAdmin(admin.ModelAdmin):
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
'fields': ('text', 'url'),
'fields': ('link_text', 'link_url'),
'classes': ('monospace',)
})
)

View File

@@ -2,24 +2,44 @@ from rest_framework import serializers
from extras import choices, models
from netbox.api import ChoiceField, WritableNestedSerializer
from netbox.api.serializers import NestedTagSerializer
from users.api.nested_serializers import NestedUserSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
'NestedImageAttachmentSerializer',
'NestedJobResultSerializer',
'NestedTagSerializer',
'NestedJournalEntrySerializer',
'NestedTagSerializer', # Defined in netbox.api.serializers
'NestedWebhookSerializer',
]
class NestedWebhookSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
class Meta:
model = models.Webhook
fields = ['id', 'url', 'display', 'name']
class NestedCustomFieldSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
class Meta:
model = models.CustomField
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
class Meta:
model = models.CustomLink
fields = ['id', 'url', 'display', 'name']
class NestedConfigContextSerializer(WritableNestedSerializer):
@@ -27,7 +47,7 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
class Meta:
model = models.ConfigContext
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer):
@@ -35,7 +55,7 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
class Meta:
model = models.ExportTemplate
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
class NestedImageAttachmentSerializer(WritableNestedSerializer):
@@ -43,15 +63,15 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
class Meta:
model = models.ImageAttachment
fields = ['id', 'url', 'name', 'image']
fields = ['id', 'url', 'display', 'name', 'image']
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
class NestedJournalEntrySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
class Meta:
model = models.Tag
fields = ['id', 'url', 'name', 'slug', 'color']
model = models.JournalEntry
fields = ['id', 'url', 'display', 'created']
class NestedJobResultSerializer(serializers.ModelSerializer):

View File

@@ -5,16 +5,15 @@ from rest_framework import serializers
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
NestedRegionSerializer, NestedSiteSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
)
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
from extras.choices import *
from extras.models import (
ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
)
from extras.models import *
from extras.utils import FeatureQuery
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.exceptions import SerializerNotFound
from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer
@@ -23,6 +22,46 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer,
from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import *
__all__ = (
'ConfigContextSerializer',
'ContentTypeSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
'ScriptDetailSerializer',
'ScriptInputSerializer',
'ScriptLogMessageSerializer',
'ScriptOutputSerializer',
'ScriptSerializer',
'TagSerializer',
'WebhookSerializer',
)
#
# Webhooks
#
class WebhookSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
many=True
)
class Meta:
model = Webhook
fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'ssl_verification', 'ca_file_path',
]
#
# Custom fields
@@ -40,11 +79,29 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class Meta:
model = CustomField
fields = [
'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
]
#
# Custom links
#
class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query())
)
class Meta:
model = CustomLink
fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
]
#
# Export templates
#
@@ -57,7 +114,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
'file_extension',
]
#
@@ -70,39 +130,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', None)
instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
else:
instance.tags.clear()
return instance
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
#
@@ -119,8 +147,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
'created',
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created',
]
def validate(self, data):
@@ -154,6 +182,51 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
return serializer(obj.parent, context={'request': self.context['request']}).data
#
# Journal entries
#
class JournalEntrySerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.all()
)
assigned_object = serializers.SerializerMethodField(read_only=True)
kind = ChoiceField(
choices=JournalEntryKindChoices,
required=False
)
class Meta:
model = JournalEntry
fields = [
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments',
]
def validate(self, data):
# Validate that the parent object exists
if 'assigned_object_type' in data and 'assigned_object_id' in data:
try:
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
except ObjectDoesNotExist:
raise serializers.ValidationError(
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
)
# Enforce model validation
super().validate(data)
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data
#
# Config contexts
#
@@ -166,6 +239,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
site_groups = SerializedPKRelatedField(
queryset=SiteGroup.objects.all(),
serializer=NestedSiteGroupSerializer,
required=False,
many=True
)
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=NestedSiteSerializer,
@@ -218,8 +297,9 @@ class ConfigContextSerializer(ValidatedModelSerializer):
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created',
'last_updated',
]
@@ -227,7 +307,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
# Job Results
#
class JobResultSerializer(serializers.ModelSerializer):
class JobResultSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
user = NestedUserSerializer(
read_only=True
@@ -240,7 +320,7 @@ class JobResultSerializer(serializers.ModelSerializer):
class Meta:
model = JobResult
fields = [
'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
]
@@ -318,7 +398,7 @@ class ScriptOutputSerializer(serializers.Serializer):
# Change logging
#
class ObjectChangeSerializer(serializers.ModelSerializer):
class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = NestedUserSerializer(
read_only=True
@@ -337,8 +417,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
class Meta:
model = ObjectChange
fields = [
'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
'changed_object_id', 'changed_object', 'object_data',
'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -365,13 +445,13 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
# ContentTypes
#
class ContentTypeSerializer(serializers.ModelSerializer):
class ContentTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
display_name = serializers.SerializerMethodField()
class Meta:
model = ContentType
fields = ['id', 'url', 'app_label', 'model', 'display_name']
fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name']
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_display_name(self, obj):

View File

@@ -5,9 +5,15 @@ from . import views
router = OrderedDefaultRouter()
router.APIRootView = views.ExtrasRootView
# Webhooks
router.register('webhooks', views.WebhookViewSet)
# Custom fields
router.register('custom-fields', views.CustomFieldViewSet)
# Custom links
router.register('custom-links', views.CustomLinkViewSet)
# Export templates
router.register('export-templates', views.ExportTemplateViewSet)
@@ -17,6 +23,9 @@ router.register('tags', views.TagViewSet)
# Image attachments
router.register('image-attachments', views.ImageAttachmentViewSet)
# Journal entries
router.register('journal-entries', views.JournalEntryViewSet)
# Config contexts
router.register('config-contexts', views.ConfigContextViewSet)

View File

@@ -11,9 +11,7 @@ from rq import Worker
from extras import filters
from extras.choices import JobResultStatusChoices
from extras.models import (
ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
)
from extras.models import *
from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
@@ -55,6 +53,17 @@ class ConfigContextQuerySetMixin:
return queryset.annotate_config_context_data()
#
# Webhooks
#
class WebhookViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Webhook.objects.all()
serializer_class = serializers.WebhookSerializer
filterset_class = filters.WebhookFilterSet
#
# Custom fields
#
@@ -84,6 +93,17 @@ class CustomFieldModelViewSet(ModelViewSet):
return context
#
# Custom links
#
class CustomLinkViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomLink.objects.all()
serializer_class = serializers.CustomLinkSerializer
filterset_class = filters.CustomLinkFilterSet
#
# Export templates
#
@@ -118,13 +138,24 @@ class ImageAttachmentViewSet(ModelViewSet):
filterset_class = filters.ImageAttachmentFilterSet
#
# Journal entries
#
class JournalEntryViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = JournalEntry.objects.all()
serializer_class = serializers.JournalEntrySerializer
filterset_class = filters.JournalEntryFilterSet
#
# Config contexts
#
class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related(
'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)
serializer_class = serializers.ConfigContextSerializer
filterset_class = filters.ConfigContextFilterSet

View File

@@ -87,6 +87,32 @@ class ObjectChangeActionChoices(ChoiceSet):
}
#
# Jounral entries
#
class JournalEntryKindChoices(ChoiceSet):
KIND_INFO = 'info'
KIND_SUCCESS = 'success'
KIND_WARNING = 'warning'
KIND_DANGER = 'danger'
CHOICES = (
(KIND_INFO, 'Info'),
(KIND_SUCCESS, 'Success'),
(KIND_WARNING, 'Warning'),
(KIND_DANGER, 'Danger'),
)
CSS_CLASSES = {
KIND_INFO: 'default',
KIND_SUCCESS: 'success',
KIND_WARNING: 'warning',
KIND_DANGER: 'danger',
}
#
# Log Levels for Reports and Scripts
#

View File

@@ -4,12 +4,12 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.forms import DateField, IntegerField, NullBooleanField
from dcim.models import DeviceRole, Platform, Region, Site
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet, ContentTypeFilter
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
from .models import *
__all__ = (
@@ -17,12 +17,15 @@ __all__ = (
'ContentTypeFilterSet',
'CreatedUpdatedFilterSet',
'CustomFieldFilter',
'CustomLinkFilterSet',
'CustomFieldModelFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
'WebhookFilterSet',
)
EXACT_FILTER_TYPES = (
@@ -33,6 +36,20 @@ EXACT_FILTER_TYPES = (
)
class WebhookFilterSet(BaseFilterSet):
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
)
class Meta:
model = Webhook
fields = [
'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
]
class CustomFieldFilter(django_filters.Filter):
"""
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
@@ -79,6 +96,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
class CustomLinkFilterSet(BaseFilterSet):
class Meta:
model = CustomLink
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
class ExportTemplateFilterSet(BaseFilterSet):
class Meta:
@@ -94,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet):
fields = ['id', 'content_type_id', 'object_id', 'name']
class JournalEntryFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFromToRangeFilter()
assigned_object_type = ContentTypeFilter()
created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label='User (ID)',
)
created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username',
queryset=User.objects.all(),
to_field_name='username',
label='User (name)',
)
kind = django_filters.MultipleChoiceFilter(
choices=JournalEntryKindChoices
)
class Meta:
model = JournalEntry
fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(comments__icontains=value)
class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -129,6 +184,17 @@ class ConfigContextFilterSet(BaseFilterSet):
to_field_name='slug',
label='Region (slug)',
)
site_group = django_filters.ModelMultipleChoiceFilter(
field_name='site_groups__slug',
queryset=SiteGroup.objects.all(),
to_field_name='slug',
label='Site group (slug)',
)
site_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='site_groups',
queryset=SiteGroup.objects.all(),
label='Site group',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='sites',
queryset=Site.objects.all(),

View File

@@ -4,16 +4,16 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, Platform, Region, Site
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
#
@@ -210,6 +210,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
queryset=Region.objects.all(),
required=False
)
site_groups = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False
@@ -249,8 +253,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConfigContext
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
@@ -280,8 +284,8 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
field_order = [
'q', 'region_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', 'tenant_group_id',
'tenant_id',
'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
'tenant_group_id', 'tenant_id',
]
q = forms.CharField(
required=False,
@@ -292,6 +296,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False,
label=_('Regions')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site groups')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
@@ -362,6 +371,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
]
#
# Journal entries
#
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
widgets = {
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),
widget=forms.MultipleHiddenInput
)
kind = forms.ChoiceField(
choices=JournalEntryKindChoices,
required=False
)
comments = forms.CharField(
required=False,
widget=forms.Textarea()
)
class Meta:
nullable_fields = []
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
model = JournalEntry
q = forms.CharField(
required=False,
label=_('Search')
)
created_after = forms.DateTimeField(
required=False,
label=_('After'),
widget=DateTimePicker()
)
created_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
)
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
)
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect2()
)
#
# Change logging
#
@@ -390,7 +471,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
@@ -399,7 +479,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
display_field='display_name',
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',

View File

@@ -1,3 +1,4 @@
from cacheops import invalidate_model
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
@@ -27,7 +28,7 @@ class Command(BaseCommand):
app_label, model_name = name.split('.')
except ValueError:
raise CommandError(
"Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
f"Invalid format: {name}. Models must be specified in the form app_label.ModelName."
)
try:
app_config = apps.get_app_config(app_label)
@@ -36,13 +37,13 @@ class Command(BaseCommand):
try:
model = app_config.get_model(model_name)
except LookupError:
raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
raise CommandError(f"Unknown model: {app_label}.{model_name}")
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if not fields:
raise CommandError(
"Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
f"Invalid model: {app_label}.{model_name} does not employ natural ordering"
)
models.append(
(model, fields)
@@ -67,7 +68,7 @@ class Command(BaseCommand):
models = self._get_models(args)
if options['verbosity']:
self.stdout.write("Renaturalizing {} models.".format(len(models)))
self.stdout.write(f"Renaturalizing {len(models)} models.")
for model, fields in models:
for field in fields:
@@ -78,7 +79,7 @@ class Command(BaseCommand):
# Print the model and field name
if options['verbosity']:
self.stdout.write(
"{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
f"{model._meta.label}.{field.target_field} ({field.name})... ",
ending='\n' if options['verbosity'] >= 2 else ''
)
self.stdout.flush()
@@ -89,23 +90,26 @@ class Command(BaseCommand):
naturalized_value = naturalize(value, max_length=field.max_length)
if options['verbosity'] >= 2:
self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='')
self.stdout.write(f" {value} -> {naturalized_value}", ending='')
self.stdout.flush()
# Update each unique field value in bulk
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
if options['verbosity'] >= 2:
self.stdout.write(" ({})".format(changed))
self.stdout.write(f" ({changed})")
count += changed
# Print the total count of alterations for the field
if options['verbosity'] >= 2:
self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
count, model._meta.verbose_name_plural, queryset.count()
)))
self.stdout.write(self.style.SUCCESS(
f"{count} {model._meta.verbose_name_plural} updated ({queryset.count()} unique values)"
))
elif options['verbosity']:
self.stdout.write(self.style.SUCCESS(str(count)))
# Invalidate cached queries
invalidate_model(model)
if options['verbosity']:
self.stdout.write(self.style.SUCCESS("Done."))

View File

@@ -1,3 +1,4 @@
import json
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -47,8 +48,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
# Print the request body (if any)
content_length = self.headers.get('Content-Length')
if content_length is not None:
body = self.rfile.read(int(content_length))
print(body.decode('utf-8'))
body = self.rfile.read(int(content_length)).decode('utf-8')
if self.headers.get('Content-Type') == 'application/json':
body = json.loads(body)
print(json.dumps(body, indent=4))
else:
print('(No body)')

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2b1 on 2021-03-03 20:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0054_standardize_models'),
]
operations = [
migrations.RenameField(
model_name='objectchange',
old_name='object_data',
new_name='postchange_data',
),
migrations.AlterField(
model_name='objectchange',
name='postchange_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='objectchange',
name='prechange_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0130_sitegroup'),
('extras', '0055_objectchange_data'),
]
operations = [
migrations.AddField(
model_name='configcontext',
name='site_groups',
field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2b1 on 2021-03-09 01:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0056_sitegroup'),
]
operations = [
migrations.RenameField(
model_name='customlink',
old_name='text',
new_name='link_text',
),
migrations.RenameField(
model_name='customlink',
old_name='url',
new_name='link_url',
),
migrations.AlterField(
model_name='customlink',
name='new_window',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,31 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0057_customlink_rename_fields'),
]
operations = [
migrations.CreateModel(
name='JournalEntry',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('assigned_object_id', models.PositiveIntegerField()),
('created', models.DateTimeField(auto_now_add=True)),
('kind', models.CharField(default='info', max_length=30)),
('comments', models.TextField()),
('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'journal entries',
'ordering': ('-created',),
},
),
]

View File

@@ -1,9 +1,7 @@
from .change_logging import ObjectChange
from .configcontexts import ConfigContext, ConfigContextModel
from .customfields import CustomField
from .models import (
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
Webhook,
)
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
from .tags import Tag, TaggedItem
__all__ = (
@@ -14,6 +12,7 @@ __all__ = (
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'ObjectChange',
'Report',
'Script',

View File

@@ -67,15 +67,22 @@ class ObjectChange(BigIDModel):
max_length=200,
editable=False
)
object_data = models.JSONField(
editable=False
prechange_data = models.JSONField(
editable=False,
blank=True,
null=True
)
postchange_data = models.JSONField(
editable=False,
blank=True,
null=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
'related_object_type', 'related_object_id', 'object_repr', 'prechange_data', 'postchange_data',
]
class Meta:
@@ -114,7 +121,8 @@ class ObjectChange(BigIDModel):
self.related_object_type,
self.related_object_id,
self.object_repr,
self.object_data,
self.prechange_data,
self.postchange_data,
)
def get_action_class(self):

View File

@@ -0,0 +1,161 @@
from collections import OrderedDict
from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from extras.querysets import ConfigContextQuerySet
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from utilities.utils import deepmerge
__all__ = (
'ConfigContext',
'ConfigContextModel',
)
#
# Config contexts
#
@extras_features('webhooks')
class ConfigContext(ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
"""
name = models.CharField(
max_length=100,
unique=True
)
weight = models.PositiveSmallIntegerField(
default=1000
)
description = models.CharField(
max_length=200,
blank=True
)
is_active = models.BooleanField(
default=True,
)
regions = models.ManyToManyField(
to='dcim.Region',
related_name='+',
blank=True
)
site_groups = models.ManyToManyField(
to='dcim.SiteGroup',
related_name='+',
blank=True
)
sites = models.ManyToManyField(
to='dcim.Site',
related_name='+',
blank=True
)
roles = models.ManyToManyField(
to='dcim.DeviceRole',
related_name='+',
blank=True
)
platforms = models.ManyToManyField(
to='dcim.Platform',
related_name='+',
blank=True
)
cluster_groups = models.ManyToManyField(
to='virtualization.ClusterGroup',
related_name='+',
blank=True
)
clusters = models.ManyToManyField(
to='virtualization.Cluster',
related_name='+',
blank=True
)
tenant_groups = models.ManyToManyField(
to='tenancy.TenantGroup',
related_name='+',
blank=True
)
tenants = models.ManyToManyField(
to='tenancy.Tenant',
related_name='+',
blank=True
)
tags = models.ManyToManyField(
to='extras.Tag',
related_name='+',
blank=True
)
data = models.JSONField()
objects = ConfigContextQuerySet.as_manager()
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
class ConfigContextModel(models.Model):
"""
A model which includes local configuration context data. This local data will override any inherited data from
ConfigContexts.
"""
local_context_data = models.JSONField(
blank=True,
null=True,
)
class Meta:
abstract = True
def get_config_context(self):
"""
Return the rendered configuration context for a device or VM.
"""
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
if not hasattr(self, 'config_context_data'):
# The annotation is not available, so we fall back to manually querying for the config context objects
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
else:
# The attribute may exist, but the annotated value could be None if there is no config context data
config_context_data = self.config_context_data or []
for context in config_context_data:
data = deepmerge(data, context)
# If the object has local config context data defined, merge it last
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
return data
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if self.local_context_data and type(self.local_context_data) is not dict:
raise ValidationError(
{'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
)

View File

@@ -1,6 +1,5 @@
import json
import uuid
from collections import OrderedDict
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
@@ -8,17 +7,27 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
from extras.constants import *
from extras.querysets import ConfigContextQuerySet
from extras.utils import extras_features, FeatureQuery, image_upload
from netbox.models import BigIDModel, ChangeLoggingMixin
from netbox.models import BigIDModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge, render_jinja2
from utilities.utils import render_jinja2
__all__ = (
'CustomLink',
'ExportTemplate',
'ImageAttachment',
'JobResult',
'JournalEntry',
'Report',
'Script',
'Webhook',
)
#
@@ -109,6 +118,8 @@ class Webhook(BigIDModel):
'Leave blank to use the system defaults.'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('name',)
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
@@ -172,13 +183,13 @@ class CustomLink(BigIDModel):
max_length=100,
unique=True
)
text = models.CharField(
link_text = models.CharField(
max_length=500,
help_text="Jinja2 template code for link text"
)
url = models.CharField(
link_url = models.CharField(
max_length=500,
verbose_name='URL',
verbose_name='Link URL',
help_text="Jinja2 template code for link URL"
)
weight = models.PositiveSmallIntegerField(
@@ -196,9 +207,12 @@ class CustomLink(BigIDModel):
help_text="The class of the first link in a group will be used for the dropdown button"
)
new_window = models.BooleanField(
default=False,
help_text="Force link to open in a new window"
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['group_name', 'weight', 'name']
@@ -358,142 +372,51 @@ class ImageAttachment(BigIDModel):
#
# Config contexts
# Journal entries
#
class ConfigContext(ChangeLoggingMixin, BigIDModel):
class JournalEntry(BigIDModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
"""
name = models.CharField(
max_length=100,
unique=True
assigned_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
weight = models.PositiveSmallIntegerField(
default=1000
assigned_object_id = models.PositiveIntegerField()
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
description = models.CharField(
max_length=200,
blank=True
created = models.DateTimeField(
auto_now_add=True
)
is_active = models.BooleanField(
default=True,
created_by = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
blank=True,
null=True
)
regions = models.ManyToManyField(
to='dcim.Region',
related_name='+',
blank=True
kind = models.CharField(
max_length=30,
choices=JournalEntryKindChoices,
default=JournalEntryKindChoices.KIND_INFO
)
sites = models.ManyToManyField(
to='dcim.Site',
related_name='+',
blank=True
)
roles = models.ManyToManyField(
to='dcim.DeviceRole',
related_name='+',
blank=True
)
platforms = models.ManyToManyField(
to='dcim.Platform',
related_name='+',
blank=True
)
cluster_groups = models.ManyToManyField(
to='virtualization.ClusterGroup',
related_name='+',
blank=True
)
clusters = models.ManyToManyField(
to='virtualization.Cluster',
related_name='+',
blank=True
)
tenant_groups = models.ManyToManyField(
to='tenancy.TenantGroup',
related_name='+',
blank=True
)
tenants = models.ManyToManyField(
to='tenancy.Tenant',
related_name='+',
blank=True
)
tags = models.ManyToManyField(
to='extras.Tag',
related_name='+',
blank=True
)
data = models.JSONField()
comments = models.TextField()
objects = ConfigContextQuerySet.as_manager()
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['weight', 'name']
ordering = ('-created',)
verbose_name_plural = 'journal entries'
def __str__(self):
return self.name
return f"{self.created} - {self.get_kind_display()}"
def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
class ConfigContextModel(models.Model):
"""
A model which includes local configuration context data. This local data will override any inherited data from
ConfigContexts.
"""
local_context_data = models.JSONField(
blank=True,
null=True,
)
class Meta:
abstract = True
def get_config_context(self):
"""
Return the rendered configuration context for a device or VM.
"""
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
if not hasattr(self, 'config_context_data'):
# The annotation is not available, so we fall back to manually querying for the config context objects
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
else:
# The attribute may exist, but the annotated value could be None if there is no config context data
config_context_data = self.config_context_data or []
for context in config_context_data:
data = deepmerge(data, context)
# If the object has local config context data defined, merge it last
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
return data
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if self.local_context_data and type(self.local_context_data) is not dict:
raise ValidationError(
{'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
def get_kind_class(self):
return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
#

View File

@@ -2,7 +2,8 @@ from django.db import models
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.models import BigIDModel, ChangeLoggingMixin
from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
@@ -12,7 +13,8 @@ from utilities.querysets import RestrictedQuerySet
# Tags
#
class Tag(ChangeLoggingMixin, BigIDModel, TagBase):
@extras_features('webhooks')
class Tag(ChangeLoggedModel, TagBase):
color = ColorField(
default=ColorChoices.COLOR_GREY
)

View File

@@ -180,28 +180,26 @@ class ObjectVar(ScriptVariable):
A single object within NetBox.
:param model: The NetBox model being referenced
:param display_field: The attribute of the returned object to display in the selection list (default: 'name')
:param display_field: The attribute of the returned object to display in the selection list (DEPRECATED)
:param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
:param null_option: The label to use as a "null" selection option (optional)
"""
form_field = DynamicModelChoiceField
def __init__(self, model=None, queryset=None, display_field='name', query_params=None, null_option=None, *args,
**kwargs):
def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
# TODO: Remove display_field in v2.12
if 'display_field' in kwargs:
warnings.warn(
"The 'display_field' parameter has been deprecated, and will be removed in NetBox v2.12. Object "
"variables will now reference the 'display' attribute available on all model serializers by default."
)
display_field = kwargs.pop('display_field', 'display')
super().__init__(*args, **kwargs)
# Set the form field's queryset. Support backward compatibility for the "queryset" argument for now.
if model is not None:
self.field_attrs['queryset'] = model.objects.all()
elif queryset is not None:
warnings.warn(
f'{self}: Specifying a queryset for ObjectVar is no longer supported. Please use "model" instead.'
)
self.field_attrs['queryset'] = queryset
else:
raise TypeError('ObjectVar must specify a model')
self.field_attrs.update({
'queryset': model.objects.all(),
'display_field': display_field,
'query_params': query_params,
'null_option': null_option,

View File

@@ -36,6 +36,9 @@ def _handle_changed_object(request, sender, instance, **kwargs):
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(action)
# TODO: Move this to to_objectchange()
if hasattr(instance, '_prechange_snapshot'):
objectchange.prechange_data = instance._prechange_snapshot
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
@@ -62,6 +65,9 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
# TODO: Move this to to_objectchange()
if hasattr(instance, '_prechange_snapshot'):
objectchange.prechange_data = instance._prechange_snapshot
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from django.conf import settings
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
TAGGED_ITEM = """
{% if value.get_absolute_url %}
@@ -96,3 +96,47 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class JournalEntryTable(BaseTable):
pk = ToggleColumn()
created = tables.DateTimeColumn(
format=settings.SHORT_DATETIME_FORMAT
)
assigned_object_type = tables.Column(
verbose_name='Object type'
)
assigned_object = tables.Column(
linkify=True,
orderable=False,
verbose_name='Object'
)
kind = ChoiceFieldColumn()
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
)
class ObjectJournalTable(BaseTable):
"""
Used for displaying a set of JournalEntries within the context of a single object.
"""
created = tables.DateTimeColumn(
format=settings.SHORT_DATETIME_FORMAT
)
kind = ChoiceFieldColumn()
actions = ButtonsColumn(
model=JournalEntry,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = JournalEntry
fields = ('created', 'created_by', 'kind', 'comments', 'actions')

View File

@@ -52,9 +52,9 @@ def custom_links(context, obj):
# Add non-grouped links
else:
try:
text_rendered = render_jinja2(cl.text, link_context)
text_rendered = render_jinja2(cl.link_text, link_context)
if text_rendered:
link_rendered = render_jinja2(cl.url, link_context)
link_rendered = render_jinja2(cl.link_url, link_context)
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
@@ -70,10 +70,10 @@ def custom_links(context, obj):
for cl in links:
try:
text_rendered = render_jinja2(cl.text, link_context)
text_rendered = render_jinja2(cl.link_text, link_context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
link_rendered = render_jinja2(cl.url, link_context)
link_rendered = render_jinja2(cl.link_url, link_context)
links_rendered.append(
GROUP_LINK.format(link_rendered, link_target, text_rendered)
)

View File

@@ -1,6 +1,7 @@
import datetime
from unittest import skipIf
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
@@ -9,9 +10,9 @@ from django_rq.queues import get_connection
from rest_framework import status
from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag
from extras.models import *
from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
@@ -30,9 +31,63 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class WebhookTest(APIViewTestCases.APIViewTestCase):
model = Webhook
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'content_types': ['dcim.device', 'dcim.devicetype'],
'name': 'Webhook 4',
'type_create': True,
'payload_url': 'http://example.com/?4',
},
{
'content_types': ['dcim.device', 'dcim.devicetype'],
'name': 'Webhook 5',
'type_update': True,
'payload_url': 'http://example.com/?5',
},
{
'content_types': ['dcim.device', 'dcim.devicetype'],
'name': 'Webhook 6',
'type_delete': True,
'payload_url': 'http://example.com/?6',
},
]
bulk_update_data = {
'ssl_verification': False,
}
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
rack_ct = ContentType.objects.get_for_model(Rack)
webhooks = (
Webhook(
name='Webhook 1',
type_create=True,
payload_url='http://example.com/?1',
),
Webhook(
name='Webhook 2',
type_update=True,
payload_url='http://example.com/?1',
),
Webhook(
name='Webhook 3',
type_delete=True,
payload_url='http://example.com/?1',
),
)
Webhook.objects.bulk_create(webhooks)
for webhook in webhooks:
webhook.content_types.add(site_ct, rack_ct)
class CustomFieldTest(APIViewTestCases.APIViewTestCase):
model = CustomField
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'content_types': ['dcim.site'],
@@ -77,9 +132,63 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct)
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'content_type': 'dcim.site',
'name': 'Custom Link 4',
'link_text': 'Link 4',
'link_url': 'http://example.com/?4',
},
{
'content_type': 'dcim.site',
'name': 'Custom Link 5',
'link_text': 'Link 5',
'link_url': 'http://example.com/?5',
},
{
'content_type': 'dcim.site',
'name': 'Custom Link 6',
'link_text': 'Link 6',
'link_url': 'http://example.com/?6',
},
]
bulk_update_data = {
'new_window': True,
}
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
custom_links = (
CustomLink(
content_type=site_ct,
name='Custom Link 1',
link_text='Link 1',
link_url='http://example.com/?1',
),
CustomLink(
content_type=site_ct,
name='Custom Link 2',
link_text='Link 2',
link_url='http://example.com/?2',
),
CustomLink(
content_type=site_ct,
name='Custom Link 3',
link_text='Link 3',
link_url='http://example.com/?3',
),
)
CustomLink.objects.bulk_create(custom_links)
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'content_type': 'dcim.device',
@@ -127,7 +236,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag
brief_fields = ['color', 'id', 'name', 'slug', 'url']
brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Tag 4',
@@ -164,7 +273,7 @@ class ImageAttachmentTest(
APIViewTestCases.DeleteObjectViewTestCase
):
model = ImageAttachment
brief_fields = ['id', 'image', 'name', 'url']
brief_fields = ['display', 'id', 'image', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -201,9 +310,59 @@ class ImageAttachmentTest(
ImageAttachment.objects.bulk_create(image_attachments)
class JournalEntryTest(APIViewTestCases.APIViewTestCase):
model = JournalEntry
brief_fields = ['created', 'display', 'id', 'url']
bulk_update_data = {
'comments': 'Overwritten',
}
@classmethod
def setUpTestData(cls):
user = User.objects.first()
site = Site.objects.create(name='Site 1', slug='site-1')
journal_entries = (
JournalEntry(
created_by=user,
assigned_object=site,
comments='Fourth entry',
),
JournalEntry(
created_by=user,
assigned_object=site,
comments='Fifth entry',
),
JournalEntry(
created_by=user,
assigned_object=site,
comments='Sixth entry',
),
)
JournalEntry.objects.bulk_create(journal_entries)
cls.create_data = [
{
'assigned_object_type': 'dcim.site',
'assigned_object_id': site.pk,
'comments': 'First entry',
},
{
'assigned_object_type': 'dcim.site',
'assigned_object_id': site.pk,
'comments': 'Second entry',
},
{
'assigned_object_type': 'dcim.site',
'assigned_object_id': site.pk,
'comments': 'Third entry',
},
]
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'name': 'Config Context 4',
@@ -382,13 +541,13 @@ class CreatedUpdatedFilterTest(APITestCase):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1')
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rack1 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42,
)
self.rack2 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42,
)
# change the created and last_updated of one

View File

@@ -40,8 +40,8 @@ class ChangeLogViewTest(ModelViewTestCase):
def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2')
form_data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'name': 'Site 1',
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC',
'cf_my_field_select': 'Bar',
@@ -56,7 +56,7 @@ class ChangeLogViewTest(ModelViewTestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
site = Site.objects.get(name='Test Site 1')
site = Site.objects.get(name='Site 1')
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
@@ -64,20 +64,21 @@ class ChangeLogViewTest(ModelViewTestCase):
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[0].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site = Site(name='Site 1', slug='site-1')
site.save()
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
site.tags.set('Tag 1', 'Tag 2')
form_data = {
'name': 'Test Site X',
'slug': 'test-site-x',
'name': 'Site X',
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF',
'cf_my_field_select': 'Foo',
@@ -100,14 +101,16 @@ class ChangeLogViewTest(ModelViewTestCase):
).first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
self.assertEqual(oc.prechange_data['name'], 'Site 1')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self):
site = Site(
name='Test Site 1',
slug='test-site-1',
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
@@ -129,15 +132,83 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
def test_bulk_update_objects(self):
sites = (
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE),
Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE),
)
Site.objects.bulk_create(sites)
form_data = {
'pk': [site.pk for site in sites],
'_apply': True,
'status': SiteStatusChoices.STATUS_PLANNED,
'description': 'New description',
}
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(form_data),
}
self.add_permissions('dcim.view_site', 'dcim.change_site')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
objectchange = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=sites[0].pk
)
self.assertEqual(objectchange.changed_object, sites[0])
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['status'], SiteStatusChoices.STATUS_ACTIVE)
self.assertEqual(objectchange.prechange_data['description'], '')
self.assertEqual(objectchange.postchange_data['status'], form_data['status'])
self.assertEqual(objectchange.postchange_data['description'], form_data['description'])
def test_bulk_delete_objects(self):
sites = (
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE),
Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE),
)
Site.objects.bulk_create(sites)
form_data = {
'pk': [site.pk for site in sites],
'confirm': True,
'_confirm': True,
}
request = {
'path': self._get_url('bulk_delete'),
'data': post_data(form_data),
}
self.add_permissions('dcim.delete_site')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
objectchange = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=sites[0].pk
)
self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site))
self.assertEqual(objectchange.changed_object_id, sites[0].pk)
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(objectchange.prechange_data['name'], sites[0].name)
self.assertEqual(objectchange.prechange_data['slug'], sites[0].slug)
self.assertEqual(objectchange.postchange_data, None)
class ChangeLogAPITest(APITestCase):
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
@@ -169,8 +240,8 @@ class ChangeLogAPITest(APITestCase):
def test_create_object(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'name': 'Site 1',
'slug': 'site-1',
'custom_fields': {
'my_field': 'ABC',
'my_field_select': 'Bar',
@@ -195,17 +266,18 @@ class ChangeLogAPITest(APITestCase):
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc_list[0].prechange_data, None)
self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site = Site(name='Site 1', slug='site-1')
site.save()
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
'name': 'Site X',
'slug': 'site-x',
'custom_fields': {
'my_field': 'DEF',
'my_field_select': 'Foo',
@@ -229,13 +301,13 @@ class ChangeLogAPITest(APITestCase):
).first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self):
site = Site(
name='Test Site 1',
slug='test-site-1',
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
@@ -255,6 +327,123 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
def test_bulk_create_objects(self):
data = (
{
'name': 'Site 1',
'slug': 'site-1',
},
{
'name': 'Site 2',
'slug': 'site-2',
},
{
'name': 'Site 3',
'slug': 'site-3',
},
)
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ObjectChange.objects.count(), 3)
site1 = Site.objects.get(pk=response.data[0]['id'])
objectchange = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site1.pk
)
self.assertEqual(objectchange.changed_object, site1)
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(objectchange.prechange_data, None)
self.assertEqual(objectchange.postchange_data['name'], data[0]['name'])
self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug'])
def test_bulk_edit_objects(self):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
data = (
{
'id': sites[0].pk,
'name': 'Site A',
'slug': 'site-A',
},
{
'id': sites[1].pk,
'name': 'Site B',
'slug': 'site-b',
},
{
'id': sites[2].pk,
'name': 'Site C',
'slug': 'site-c',
},
)
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ObjectChange.objects.count(), 3)
objectchange = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=sites[0].pk
)
self.assertEqual(objectchange.changed_object, sites[0])
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
self.assertEqual(objectchange.postchange_data['name'], data[0]['name'])
self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug'])
def test_bulk_delete_objects(self):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
data = (
{
'id': sites[0].pk,
},
{
'id': sites[1].pk,
},
{
'id': sites[2].pk,
},
)
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.delete_site')
response = self.client.delete(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ObjectChange.objects.count(), 3)
objectchange = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=sites[0].pk
)
self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site))
self.assertEqual(objectchange.changed_object_id, sites[0].pk)
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
self.assertEqual(objectchange.postchange_data, None)

View File

@@ -4,15 +4,150 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Rack, Region, Site
from extras.choices import ObjectChangeActionChoices
from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
from extras.filters import *
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag
from extras.models import *
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType
class WebhookTestCase(TestCase):
queryset = Webhook.objects.all()
filterset = WebhookFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
webhooks = (
Webhook(
name='Webhook 1',
type_create=True,
payload_url='http://example.com/?1',
enabled=True,
http_method='GET',
ssl_verification=True,
),
Webhook(
name='Webhook 2',
type_update=True,
payload_url='http://example.com/?2',
enabled=True,
http_method='POST',
ssl_verification=True,
),
Webhook(
name='Webhook 3',
type_delete=True,
payload_url='http://example.com/?3',
enabled=False,
http_method='PATCH',
ssl_verification=False,
),
)
Webhook.objects.bulk_create(webhooks)
webhooks[0].content_types.add(content_types[0])
webhooks[1].content_types.add(content_types[1])
webhooks[2].content_types.add(content_types[2])
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Webhook 1', 'Webhook 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_create(self):
params = {'type_create': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_update(self):
params = {'type_update': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_delete(self):
params = {'type_delete': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_http_method(self):
params = {'http_method': ['GET', 'POST']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_ssl_verification(self):
params = {'ssl_verification': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomLinkTestCase(TestCase):
queryset = CustomLink.objects.all()
filterset = CustomLinkFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
custom_links = (
CustomLink(
name='Custom Link 1',
content_type=content_types[0],
weight=100,
new_window=False,
link_text='Link 1',
link_url='http://example.com/?1'
),
CustomLink(
name='Custom Link 2',
content_type=content_types[1],
weight=200,
new_window=False,
link_text='Link 1',
link_url='http://example.com/?2'
),
CustomLink(
name='Custom Link 3',
content_type=content_types[2],
weight=300,
new_window=True,
link_text='Link 1',
link_url='http://example.com/?3'
),
)
CustomLink.objects.bulk_create(custom_links)
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Custom Link 1', 'Custom Link 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self):
params = {'content_type': ContentType.objects.get(model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self):
params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_new_window(self):
params = {'new_window': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'new_window': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ExportTemplateTestCase(TestCase):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet
@@ -120,6 +255,100 @@ class ImageAttachmentTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class JournalEntryTestCase(TestCase):
queryset = JournalEntry.objects.all()
filterset = JournalEntryFilterSet
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
)
Rack.objects.bulk_create(racks)
users = (
User(username='Alice'),
User(username='Bob'),
User(username='Charlie'),
)
User.objects.bulk_create(users)
journal_entries = (
JournalEntry(
assigned_object=sites[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
),
JournalEntry(
assigned_object=sites[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
),
JournalEntry(
assigned_object=sites[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
),
JournalEntry(
assigned_object=racks[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
),
)
JournalEntry.objects.bulk_create(journal_entries)
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_created_by(self):
users = User.objects.filter(username__in=['Alice', 'Bob'])
params = {'created_by': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'created_by_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_object_type(self):
params = {'assigned_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_assigned_object(self):
params = {
'assigned_object_type': 'dcim.site',
'assigned_object_id': [Site.objects.first().pk],
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_kind(self):
params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet
@@ -132,10 +361,17 @@ class ConfigContextTestCase(TestCase):
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1'),
Site(name='Test Site 2', slug='test-site-2'),
@@ -195,6 +431,7 @@ class ConfigContextTestCase(TestCase):
data='{"foo": 123}'
)
c.regions.set([regions[i]])
c.site_groups.set([site_groups[i]])
c.sites.set([sites[i]])
c.roles.set([device_roles[i]])
c.platforms.set([platforms[i]])
@@ -224,6 +461,13 @@ class ConfigContextTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -327,7 +571,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[0],
@@ -336,7 +580,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
@@ -345,7 +589,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
@@ -354,7 +598,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
@@ -363,7 +607,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
@@ -372,7 +616,7 @@ class ObjectChangeTestCase(TestCase):
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
)
ObjectChange.objects.bulk_create(object_changes)

View File

@@ -3,12 +3,11 @@ import uuid
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, CustomLink, ObjectChange, Tag
from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase
@@ -128,6 +127,43 @@ class ObjectChangeTestCase(TestCase):
self.assertHttpStatus(response, 200)
class JournalEntryTestCase(
# ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = JournalEntry
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
site = Site.objects.create(name='Site 1', slug='site-1')
user = User.objects.create(username='User 1')
JournalEntry.objects.bulk_create((
JournalEntry(assigned_object=site, created_by=user, comments='First entry'),
JournalEntry(assigned_object=site, created_by=user, comments='Second entry'),
JournalEntry(assigned_object=site, created_by=user, comments='Third entry'),
))
cls.form_data = {
'assigned_object_type': site_ct.pk,
'assigned_object_id': site.pk,
'kind': 'info',
'comments': 'A new entry',
}
cls.bulk_edit_data = {
'kind': 'success',
'comments': 'Overwritten',
}
class CustomLinkTest(TestCase):
user_permissions = ['dcim.view_site']
@@ -135,8 +171,8 @@ class CustomLinkTest(TestCase):
customlink = CustomLink(
content_type=ContentType.objects.get_for_model(Site),
name='Test',
text='FOO {{ obj.name }} BAR',
url='http://example.com/?site={{ obj.slug }}',
link_text='FOO {{ obj.name }} BAR',
link_url='http://example.com/?site={{ obj.slug }}',
new_window=False
)
customlink.save()

View File

@@ -56,10 +56,10 @@ class WebhookTest(APITestCase):
# Verify that a job was queued for the object creation webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_create=True))
self.assertEqual(job.args[1]['id'], response.data['id'])
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True))
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
def test_enqueue_webhook_update(self):
# Update an object via the REST API
@@ -75,10 +75,10 @@ class WebhookTest(APITestCase):
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_update=True))
self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True))
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
def test_enqueue_webhook_delete(self):
# Delete an object via the REST API
@@ -91,10 +91,10 @@ class WebhookTest(APITestCase):
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_delete=True))
self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True))
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
def test_webhooks_worker(self):
@@ -116,7 +116,7 @@ class WebhookTest(APITestCase):
# Validate the outgoing request body
body = json.loads(request.body)
self.assertEqual(body['event'], 'created')
self.assertEqual(body['timestamp'], job.args[4])
self.assertEqual(body['timestamp'], job.kwargs['timestamp'])
self.assertEqual(body['model'], 'site')
self.assertEqual(body['username'], 'testuser')
self.assertEqual(body['request_id'], str(request_id))
@@ -138,4 +138,4 @@ class WebhookTest(APITestCase):
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
process_webhook(*job.args)
process_webhook(**job.kwargs)

View File

@@ -31,6 +31,14 @@ urlpatterns = [
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Journal entries
path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'),
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),

View File

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from django_rq.queues import get_connection
from django_tables2 import RequestConfig
@@ -16,7 +17,7 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di
from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
@@ -178,16 +179,18 @@ class ObjectChangeView(generic.ObjectView):
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
if prev_change:
if instance.prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prev_change.object_data,
instance.object_data,
instance.prechange_data or dict(),
instance.postchange_data or dict(),
exclude=['last_updated'],
)
diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
diff_removed = {
x: instance.prechange_data.get(x) for x in diff_added
} if instance.prechange_data else {}
else:
# No previous change; this is the initial change that added the object
diff_added = diff_removed = instance.object_data
diff_added = None
diff_removed = None
return {
'diff_added': diff_added,
@@ -279,6 +282,120 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
return imageattachment.parent.get_absolute_url()
#
# Journal entries
#
class JournalEntryListView(generic.ObjectListView):
queryset = JournalEntry.objects.all()
filterset = filters.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
action_buttons = ('export',)
class JournalEntryEditView(generic.ObjectEditView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryForm
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
obj.created_by = request.user
return obj
def get_return_url(self, request, instance):
if not instance.assigned_object:
return reverse('extras:journalentry_list')
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
return reverse(viewname, kwargs={'pk': obj.pk})
class JournalEntryDeleteView(generic.ObjectDeleteView):
queryset = JournalEntry.objects.all()
def get_return_url(self, request, instance):
obj = instance.assigned_object
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal'
return reverse(viewname, kwargs={'pk': obj.pk})
class JournalEntryBulkEditView(generic.BulkEditView):
queryset = JournalEntry.objects.prefetch_related('created_by')
filterset = filters.JournalEntryFilterSet
table = tables.JournalEntryTable
form = forms.JournalEntryBulkEditForm
class JournalEntryBulkDeleteView(generic.BulkDeleteView):
queryset = JournalEntry.objects.prefetch_related('created_by')
filterset = filters.JournalEntryFilterSet
table = tables.JournalEntryTable
class ObjectJournalView(View):
"""
Show all journal entries for an object.
base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
"""
base_template = None
def get(self, request, model, **kwargs):
# Handle QuerySet restriction of parent object if needed
if hasattr(model.objects, 'restrict'):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
else:
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter(
assigned_object_type=content_type,
assigned_object_id=obj.pk
)
journalentry_table = tables.ObjectJournalTable(
data=journalentries,
orderable=False
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(journalentry_table)
if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm(
initial={
'assigned_object_type': ContentType.objects.get_for_model(obj),
'assigned_object_id': obj.pk
}
)
else:
form = None
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html.
if self.base_template is None:
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
# TODO: This can be removed once an object view has been established for every model.
try:
template.loader.get_template(self.base_template)
except template.TemplateDoesNotExist:
self.base_template = 'base.html'
return render(request, 'extras/object_journal.html', {
'object': obj,
'form': form,
'table': journalentry_table,
'base_template': self.base_template,
'active_tab': 'journal',
})
#
# Reports
#

View File

@@ -6,6 +6,7 @@ from django.utils import timezone
from django_rq import get_queue
from utilities.api import get_serializer_for_model
from utilities.utils import serialize_object
from .choices import *
from .models import Webhook
from .registry import registry
@@ -44,6 +45,7 @@ def enqueue_webhooks(instance, user, request_id, action):
webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True})
if webhooks.exists():
# Get the Model's API serializer class and serialize the object
serializer_class = get_serializer_for_model(instance.__class__)
serializer_context = {
@@ -51,16 +53,23 @@ def enqueue_webhooks(instance, user, request_id, action):
}
serializer = serializer_class(instance, context=serializer_context)
# Gather pre- and post-change snapshots
snapshots = {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
}
# Enqueue the webhooks
webhook_queue = get_queue('default')
for webhook in webhooks:
webhook_queue.enqueue(
"extras.webhooks_worker.process_webhook",
webhook,
serializer.data,
instance._meta.model_name,
action,
str(timezone.now()),
user.username,
request_id
webhook=webhook,
model_name=instance._meta.model_name,
event=action,
data=serializer.data,
snapshots=snapshots,
timestamp=str(timezone.now()),
username=user.username,
request_id=request_id
)

View File

@@ -12,7 +12,7 @@ logger = logging.getLogger('netbox.webhooks_worker')
@job('default')
def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id):
"""
Make a POST request to the defined Webhook
"""
@@ -22,7 +22,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'model': model_name,
'username': username,
'request_id': request_id,
'data': data
'data': data,
'snapshots': snapshots,
}
# Build the headers for the HTTP request

View File

@@ -27,7 +27,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
class Meta:
model = models.VRF
fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count']
fields = ['id', 'url', 'display', 'name', 'rd', 'display_name', 'prefix_count']
#
@@ -39,7 +39,7 @@ class NestedRouteTargetSerializer(WritableNestedSerializer):
class Meta:
model = models.RouteTarget
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'display', 'name']
#
@@ -52,7 +52,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
class Meta:
model = models.RIR
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'aggregate_count']
class NestedAggregateSerializer(WritableNestedSerializer):
@@ -61,7 +61,7 @@ class NestedAggregateSerializer(WritableNestedSerializer):
class Meta:
model = models.Aggregate
fields = ['id', 'url', 'family', 'prefix']
fields = ['id', 'url', 'display', 'family', 'prefix']
#
@@ -75,7 +75,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
class Meta:
model = models.Role
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count']
class NestedVLANGroupSerializer(WritableNestedSerializer):
@@ -84,7 +84,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer):
class Meta:
model = models.VLANGroup
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
fields = ['id', 'url', 'display', 'name', 'slug', 'vlan_count']
class NestedVLANSerializer(WritableNestedSerializer):
@@ -92,7 +92,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
class Meta:
model = models.VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
fields = ['id', 'url', 'display', 'vid', 'name', 'display_name']
#
@@ -105,7 +105,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
class Meta:
model = models.Prefix
fields = ['id', 'url', 'family', 'prefix']
fields = ['id', 'url', 'display', 'family', 'prefix']
#
@@ -118,7 +118,7 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
class Meta:
model = models.IPAddress
fields = ['id', 'url', 'family', 'address']
fields = ['id', 'url', 'display', 'family', 'address']
#
@@ -130,4 +130,4 @@ class NestedServiceSerializer(WritableNestedSerializer):
class Meta:
model = models.Service
fields = ['id', 'url', 'name', 'protocol', 'ports']
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']

View File

@@ -6,13 +6,12 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from netbox.api.serializers import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer
from netbox.api.serializers import PrimaryModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -23,7 +22,7 @@ from .nested_serializers import *
# VRFs
#
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class VRFSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
import_targets = SerializedPKRelatedField(
@@ -44,8 +43,9 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = VRF
fields = [
'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets',
'export_targets', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
'prefix_count',
]
@@ -53,14 +53,14 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
# Route targets
#
class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class RouteTargetSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta:
model = RouteTarget
fields = [
'id', 'url', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
@@ -75,12 +75,12 @@ class RIRSerializer(OrganizationalModelSerializer):
class Meta:
model = RIR
fields = [
'id', 'url', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', 'last_updated',
'aggregate_count',
'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created',
'last_updated', 'aggregate_count',
]
class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class AggregateSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer()
@@ -89,8 +89,8 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Aggregate
fields = [
'id', 'url', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']
@@ -107,21 +107,28 @@ class RoleSerializer(OrganizationalModelSerializer):
class Meta:
model = Role
fields = [
'id', 'url', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated',
'prefix_count', 'vlan_count',
]
class VLANGroupSerializer(OrganizationalModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
site = NestedSiteSerializer(required=False, allow_null=True)
scope_type = ContentTypeField(
queryset=ContentType.objects.filter(
app_label='dcim',
model__in=['region', 'sitegroup', 'site', 'location', 'rack']
),
required=False
)
scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
fields = [
'id', 'url', 'name', 'slug', 'site', 'description', 'custom_fields', 'created', 'last_updated',
'vlan_count',
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields',
'created', 'last_updated', 'vlan_count',
]
validators = []
@@ -138,8 +145,16 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
return data
def get_scope(self, obj):
if obj.scope_id is None:
return None
serializer = get_serializer_for_model(obj.scope, prefix='Nested')
context = {'request': self.context['request']}
class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
return serializer(obj.scope, context=context).data
class VLANSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True)
@@ -151,7 +166,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = VLAN
fields = [
'id', 'url', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
'display_name', 'custom_fields', 'created', 'last_updated', 'prefix_count',
]
validators = []
@@ -174,7 +189,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
# Prefixes
#
class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class PrefixSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True)
@@ -187,7 +202,7 @@ class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Prefix
fields = [
'id', 'url', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']
@@ -244,7 +259,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
# IP addresses
#
class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class IPAddressSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -263,7 +278,7 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = IPAddress
fields = [
'id', 'url', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]
@@ -302,7 +317,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class ServiceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
@@ -317,6 +332,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta:
model = Service
fields = [
'id', 'url', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -283,7 +283,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
#
class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
serializer_class = serializers.VLANGroupSerializer

View File

@@ -1,15 +1,16 @@
import django_filters
import netaddr
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from netaddr.core import AddrFormatError
from dcim.models import Device, Interface, Region, Site
from dcim.models import Device, Interface, Region, Site, SiteGroup
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter,
TreeNodeMultipleChoiceFilter,
BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
@@ -192,7 +193,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
field_name='prefix',
lookup_expr='family'
)
prefix = django_filters.CharFilter(
prefix = MultiValueCharFilter(
method='filter_prefix',
label='Prefix',
)
@@ -254,6 +255,19 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -304,13 +318,13 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
return queryset.filter(qs_filter)
def filter_prefix(self, queryset, name, value):
if not value.strip():
return queryset
try:
query = str(netaddr.IPNetwork(value).cidr)
return queryset.filter(prefix=query)
except (AddrFormatError, ValueError):
return queryset.none()
query_values = []
for v in value:
try:
query_values.append(netaddr.IPNetwork(v))
except (AddrFormatError, ValueError):
pass
return queryset.filter(prefix__in=query_values)
def search_within(self, queryset, name, value):
value = value.strip()
@@ -522,33 +536,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
scope_type = ContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
sitegroup = django_filters.NumberFilter(
method='filter_scope'
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
site = django_filters.NumberFilter(
method='filter_scope'
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
location = django_filters.NumberFilter(
method='filter_scope'
)
rack = django_filters.NumberFilter(
method='filter_scope'
)
clustergroup = django_filters.NumberFilter(
method='filter_scope'
)
cluster = django_filters.NumberFilter(
method='filter_scope'
)
class Meta:
model = VLANGroup
fields = ['id', 'name', 'slug', 'description']
fields = ['id', 'name', 'slug', 'description', 'scope_id']
def filter_scope(self, queryset, name, value):
return queryset.filter(
scope_type=ContentType.objects.get(model=name),
scope_id=value
)
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
@@ -569,6 +588,19 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Rack, Region, Site
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
)
@@ -13,7 +13,7 @@ from utilities.forms import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, VirtualMachine, VMInterface
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
@@ -217,6 +217,24 @@ class RIRCSVForm(CustomFieldModelCSVForm):
}
class RIRBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RIR.objects.all(),
widget=forms.MultipleHiddenInput
)
is_private = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['is_private', 'description']
class RIRFilterForm(BootstrapMixin, forms.Form):
is_private = forms.NullBooleanField(
required=False,
@@ -351,6 +369,23 @@ class RoleCSVForm(CustomFieldModelCSVForm):
fields = Role.csv_headers
class RoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Role.objects.all(),
widget=forms.MultipleHiddenInput
)
weight = forms.IntegerField(
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
#
# Prefixes
#
@@ -359,8 +394,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
@@ -369,12 +403,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region'
'region_id': '$region',
'group_id': '$site_group',
}
)
vlan_group = DynamicModelChoiceField(
@@ -393,7 +435,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=VLAN.objects.all(),
required=False,
label='VLAN',
display_field='display_name',
query_params={
'site_id': '$site',
'group_id': '$vlan_group',
@@ -416,7 +457,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
]
fieldsets = (
('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')),
('Site/VLAN Assignment', ('region', 'site', 'vlan_group', 'vlan')),
('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
('Tenancy', ('tenant_group', 'tenant')),
)
widgets = {
@@ -497,18 +538,22 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Region.objects.all(),
required=False
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region'
'region_id': '$region',
'group_id': '$site_group',
}
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
prefix_length = forms.IntegerField(
min_value=PREFIX_LENGTH_MIN,
@@ -547,8 +592,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Prefix
field_order = [
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id', 'site_id',
'role_id', 'tenant_group_id', 'tenant_id', 'is_pool',
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id',
'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool',
]
mask_length__lte = forms.IntegerField(
widget=forms.HiddenInput()
@@ -599,6 +644,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
@@ -632,7 +682,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
display_field='display_name',
initial_params={
'interfaces': '$interface'
}
@@ -662,8 +711,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
nat_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
@@ -673,19 +721,27 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
'sites': '$nat_site'
}
)
nat_site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label='Site group',
initial_params={
'sites': '$nat_site'
}
)
nat_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
query_params={
'region_id': '$nat_region'
'region_id': '$nat_region',
'group_id': '$nat_site_group',
}
)
nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
display_field='display_name',
null_option='None',
query_params={
'site_id': '$site'
@@ -695,7 +751,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
queryset=Device.objects.all(),
required=False,
label='Device',
display_field='display_name',
query_params={
'site_id': '$site',
'rack_id': '$nat_rack',
@@ -717,14 +772,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
nat_vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
nat_inside = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
required=False,
label='IP Address',
display_field='address',
query_params={
'device_id': '$nat_device',
'virtual_machine_id': '$nat_virtual_machine',
@@ -833,8 +886,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -965,8 +1017,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
display_field='display_name'
label='VRF'
)
mask_length = forms.IntegerField(
min_value=IPADDRESS_MASK_LENGTH_MIN,
@@ -1089,11 +1140,54 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
initial_params={
'locations': '$location'
},
query_params={
'region_id': '$region'
'region_id': '$region',
'group_id': '$site_group',
}
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
initial_params={
'racks': '$rack'
},
query_params={
'site_id': '$site',
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site',
'location_id': '$location',
}
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
initial_params={
'clusters': '$cluster'
}
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
query_params={
'group_id': '$cluster_group',
}
)
slug = SlugField()
@@ -1101,9 +1195,43 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = VLANGroup
fields = [
'region', 'site', 'name', 'slug', 'description',
'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', 'cluster_group',
'cluster',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.scope:
if type(instance.scope) is Rack:
initial['rack'] = instance.scope
elif type(instance.scope) is Location:
initial['location'] = instance.scope
elif type(instance.scope) is Site:
initial['site'] = instance.scope
elif type(instance.scope) is SiteGroup:
initial['site_group'] = instance.scope
elif type(instance.scope) is Region:
initial['region'] = instance.scope
elif type(instance.scope) is Cluster:
initial['cluster'] = instance.scope
elif type(instance.scope) is ClusterGroup:
initial['cluster_group'] = instance.scope
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
# Assign scope object
self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or \
self.cleaned_data['site'] or self.cleaned_data['site_group'] or \
self.cleaned_data['region'] or self.cleaned_data['cluster'] or \
self.cleaned_data['cluster_group'] or None
class VLANGroupCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
@@ -1119,21 +1247,50 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm):
fields = VLANGroup.csv_headers
class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['site', 'description']
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region_id = DynamicModelMultipleChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
sitegroup = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
location = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location')
)
rack = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack')
)
#
@@ -1141,28 +1298,59 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
#
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
# VLANGroup assignment fields
scope_type = forms.ChoiceField(
choices=(
('', ''),
('dcim.region', 'Region'),
('dcim.sitegroup', 'Site group'),
('dcim.site', 'Site'),
('dcim.location', 'Location'),
('dcim.rack', 'Rack'),
('virtualization.clustergroup', 'Cluster group'),
('virtualization.cluster', 'Cluster'),
),
required=False,
widget=StaticSelect2,
label='Group scope'
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
query_params={
'scope_type': '$scope_type',
},
label='VLAN Group'
)
# Site assignment fields
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
},
label='Region'
)
sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
},
label='Site group'
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region'
}
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
query_params={
'site_id': '$site'
'region_id': '$region',
'group_id': '$sitegroup',
}
)
# Other fields
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False
@@ -1177,11 +1365,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
fields = [
'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags',
]
fieldsets = (
('VLAN', ('vid', 'name', 'status', 'role', 'description', 'tags')),
('Assignment', ('region', 'site', 'group')),
('Tenancy', ('tenant_group', 'tenant')),
)
help_texts = {
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
@@ -1233,15 +1416,6 @@ class VLANCSVForm(CustomFieldModelCSVForm):
'name': 'VLAN name',
}
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit vlan queryset by assigned group
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['group'].queryset = self.fields['group'].queryset.filter(**params)
class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1252,11 +1426,16 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
queryset=Region.objects.all(),
required=False
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region'
'region_id': '$region',
'group_id': '$site_group',
}
)
group = DynamicModelChoiceField(
@@ -1292,7 +1471,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = VLAN
field_order = ['q', 'region_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_order = [
'q', 'region_id', 'site_group_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
]
q = forms.CharField(
required=False,
label='Search'
@@ -1302,6 +1483,11 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,

View File

@@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0044_standardize_models'),
]
operations = [
migrations.RenameField(
model_name='vlangroup',
old_name='site',
new_name='scope_id',
),
migrations.AlterField(
model_name='vlangroup',
name='scope_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='vlangroup',
name='scope_type',
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
),
migrations.AlterModelOptions(
name='vlangroup',
options={'ordering': ('name', 'pk'), 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
),
migrations.AlterUniqueTogether(
name='vlangroup',
unique_together={('scope_type', 'scope_id', 'name'), ('scope_type', 'scope_id', 'slug')},
),
]

View File

@@ -6,10 +6,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.models import Device
from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel
from ipam.choices import *
@@ -19,7 +17,6 @@ from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
@@ -108,7 +105,6 @@ class Aggregate(PrimaryModel):
max_length=200,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -293,7 +289,6 @@ class Prefix(PrimaryModel):
max_length=200,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = PrefixQuerySet.as_manager()
@@ -565,7 +560,6 @@ class IPAddress(PrimaryModel):
max_length=200,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = IPAddressManager()
@@ -649,13 +643,7 @@ class IPAddress(PrimaryModel):
def to_objectchange(self, action):
# Annotate the assigned object, if any
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=self.assigned_object,
object_data=serialize_object(self)
)
return super().to_objectchange(action, related_object=self.assigned_object)
def to_csv(self):

View File

@@ -3,9 +3,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from extras.models import TaggedItem
from extras.utils import extras_features
from ipam.choices import *
from ipam.constants import *
@@ -66,7 +64,6 @@ class Service(PrimaryModel):
max_length=200,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()

View File

@@ -1,11 +1,11 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.models import Interface
from extras.models import TaggedItem
from extras.utils import extras_features
from ipam.choices import *
from ipam.constants import *
@@ -31,13 +31,23 @@ class VLANGroup(OrganizationalModel):
slug = models.SlugField(
max_length=100
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='vlan_groups',
scope_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=Q(
model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']
),
blank=True,
null=True
)
scope_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
scope = GenericForeignKey(
ct_field='scope_type',
fk_field='scope_id'
)
description = models.CharField(
max_length=200,
blank=True
@@ -45,13 +55,13 @@ class VLANGroup(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'site', 'description']
csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description']
class Meta:
ordering = ('site', 'name', 'pk') # (site, name) may be non-unique
ordering = ('name', 'pk') # Name may be non-unique
unique_together = [
['site', 'name'],
['site', 'slug'],
['scope_type', 'scope_id', 'name'],
['scope_type', 'scope_id', 'slug'],
]
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
@@ -62,11 +72,21 @@ class VLANGroup(OrganizationalModel):
def get_absolute_url(self):
return reverse('ipam:vlangroup_vlans', args=[self.pk])
def clean(self):
super().clean()
# Validate scope assignment
if self.scope_type and not self.scope_id:
raise ValidationError("Cannot set scope_type without scope_id.")
if self.scope_id and not self.scope_type:
raise ValidationError("Cannot set scope_id without scope_type.")
def to_csv(self):
return (
self.name,
self.slug,
self.site.name if self.site else None,
f'{self.scope_type.app_label}.{self.scope_type.model}',
self.scope_id,
self.description,
)
@@ -135,7 +155,6 @@ class VLAN(PrimaryModel):
max_length=200,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -162,10 +181,11 @@ class VLAN(PrimaryModel):
def clean(self):
super().clean()
# Validate VLAN group
if self.group and self.group.site != self.site:
# Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site:
raise ValidationError({
'group': "VLAN group must belong to the assigned site ({}).".format(self.site)
'group': f"VLAN is assigned to group {self.group} (scope: {self.group.scope}); cannot also assign to "
f"site {self.site}."
})
def to_csv(self):

View File

@@ -1,8 +1,6 @@
from django.db import models
from django.urls import reverse
from taggit.managers import TaggableManager
from extras.models import TaggedItem
from extras.utils import extras_features
from ipam.constants import *
from netbox.models import PrimaryModel
@@ -59,7 +57,6 @@ class VRF(PrimaryModel):
related_name='exporting_vrfs',
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
@@ -116,7 +113,6 @@ class RouteTarget(PrimaryModel):
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()

View File

@@ -3,20 +3,16 @@ from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn,
UtilizationColumn,
)
from virtualization.models import VMInterface
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
UTILIZATION_GRAPH = """
{% load helpers %}
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
"""
PREFIX_LINK = """
{% load helpers %}
{% for i in record.parents|as_range %}
@@ -37,7 +33,7 @@ IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %}
@@ -50,8 +46,8 @@ IPADDRESS_ASSIGN_LINK = """
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
{% elif prefix.vrf %}
{{ prefix.vrf }}
{% elif object.vrf %}
<a href="{{ object.vrf.get_absolute_url }}">{{ object.vrf }}</a>
{% else %}
Global
{% endif %}
@@ -94,7 +90,7 @@ VLAN_ROLE_LINK = """
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
@@ -109,16 +105,6 @@ VLAN_MEMBER_TAGGED = """
{% endif %}
"""
TENANT_LINK = """
{% if record.tenant %}
<a href="{{ record.tenant.get_absolute_url }}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
{% elif record.vrf.tenant %}
<a href="{{ record.vrf.tenant.get_absolute_url }}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
{% else %}
&mdash;
{% endif %}
"""
#
# VRFs
@@ -130,9 +116,7 @@ class VRFTable(BaseTable):
rd = tables.Column(
verbose_name='RD'
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
@@ -163,9 +147,7 @@ class VRFTable(BaseTable):
class RouteTargetTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vrf_list'
)
@@ -208,9 +190,7 @@ class AggregateTable(BaseTable):
prefix = tables.LinkColumn(
verbose_name='Aggregate'
)
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
tenant = TenantColumn()
date_added = tables.DateColumn(
format="Y-m-d",
verbose_name='Added'
@@ -225,8 +205,8 @@ class AggregateDetailTable(AggregateTable):
child_count = tables.Column(
verbose_name='Prefixes'
)
utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
utilization = UtilizationColumn(
accessor='get_utilization',
orderable=False
)
tags = TagColumn(
@@ -279,9 +259,7 @@ class PrefixTable(BaseTable):
template_code=VRF_LINK,
verbose_name='VRF'
)
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
@@ -308,13 +286,11 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable):
utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
utilization = UtilizationColumn(
accessor='get_utilization',
orderable=False
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:prefix_list'
)
@@ -347,9 +323,7 @@ class IPAddressTable(BaseTable):
default=AVAILABLE_LABEL
)
role = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
tenant = TenantColumn()
assigned_object = tables.Column(
linkify=True,
orderable=False,
@@ -379,9 +353,7 @@ class IPAddressDetailTable(IPAddressTable):
orderable=False,
verbose_name='NAT (Inside)'
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
assigned = BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
@@ -428,8 +400,9 @@ class InterfaceIPAddressTable(BaseTable):
verbose_name='VRF'
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
tenant = TenantColumn()
actions = ButtonsColumn(
model=IPAddress
)
class Meta(BaseTable.Meta):
@@ -444,7 +417,7 @@ class InterfaceIPAddressTable(BaseTable):
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
site = tables.Column(
scope = tables.Column(
linkify=True
)
vlan_count = LinkedCountColumn(
@@ -459,8 +432,8 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions')
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
#
@@ -480,9 +453,7 @@ class VLANTable(BaseTable):
viewname='ipam:vlangroup_vlans',
args=[Accessor('group__pk')]
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
@@ -504,9 +475,7 @@ class VLANDetailTable(VLANTable):
orderable=False,
verbose_name='Prefixes'
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vlan_list'
)
@@ -564,9 +533,7 @@ class InterfaceVLANTable(BaseTable):
accessor=Accessor('group__name'),
verbose_name='Group'
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK

View File

@@ -22,7 +22,7 @@ class AppTest(APITestCase):
class VRFTest(APIViewTestCases.APIViewTestCase):
model = VRF
brief_fields = ['display_name', 'id', 'name', 'prefix_count', 'rd', 'url']
brief_fields = ['display', 'display_name', 'id', 'name', 'prefix_count', 'rd', 'url']
create_data = [
{
'name': 'VRF 4',
@@ -54,7 +54,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
class RouteTargetTest(APIViewTestCases.APIViewTestCase):
model = RouteTarget
brief_fields = ['id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'name': '65000:1004',
@@ -83,7 +83,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase):
class RIRTest(APIViewTestCases.APIViewTestCase):
model = RIR
brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url']
brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'RIR 4',
@@ -115,7 +115,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase):
class AggregateTest(APIViewTestCases.APIViewTestCase):
model = Aggregate
brief_fields = ['family', 'id', 'prefix', 'url']
brief_fields = ['display', 'family', 'id', 'prefix', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -154,7 +154,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
class RoleTest(APIViewTestCases.APIViewTestCase):
model = Role
brief_fields = ['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
create_data = [
{
'name': 'Role 4',
@@ -186,7 +186,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
class PrefixTest(APIViewTestCases.APIViewTestCase):
model = Prefix
brief_fields = ['family', 'id', 'prefix', 'url']
brief_fields = ['display', 'family', 'id', 'prefix', 'url']
create_data = [
{
'prefix': '192.168.4.0/24',
@@ -360,7 +360,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
class IPAddressTest(APIViewTestCases.APIViewTestCase):
model = IPAddress
brief_fields = ['address', 'family', 'id', 'url']
brief_fields = ['address', 'display', 'family', 'id', 'url']
create_data = [
{
'address': '192.168.0.4/24',
@@ -389,7 +389,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
class VLANGroupTest(APIViewTestCases.APIViewTestCase):
model = VLANGroup
brief_fields = ['id', 'name', 'slug', 'url', 'vlan_count']
brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count']
create_data = [
{
'name': 'VLAN Group 4',
@@ -421,7 +421,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
class VLANTest(APIViewTestCases.APIViewTestCase):
model = VLAN
brief_fields = ['display_name', 'id', 'name', 'url', 'vid']
brief_fields = ['display', 'display_name', 'id', 'name', 'url', 'vid']
bulk_update_data = {
'description': 'New description',
}
@@ -481,7 +481,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
class ServiceTest(APIViewTestCases.APIViewTestCase):
model = Service
brief_fields = ['id', 'name', 'ports', 'protocol', 'url']
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
bulk_update_data = {
'description': 'New description',
}

View File

@@ -1,10 +1,10 @@
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
from ipam.choices import *
from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup
@@ -343,14 +343,21 @@ class PrefixTestCase(TestCase):
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -422,6 +429,11 @@ class PrefixTestCase(TestCase):
params = {'family': '6'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_prefix(self):
prefixes = Prefix.objects.all()[:2]
params = {'prefix': [prefixes[0].prefix, prefixes[1].prefix]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_pool(self):
params = {'is_pool': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -468,6 +480,13 @@ class PrefixTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -696,27 +715,39 @@ class VLANGroupTestCase(TestCase):
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
region = Region(name='Region 1', slug='region-1')
region.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
)
Site.objects.bulk_create(sites)
sitegroup = SiteGroup(name='Site Group 1', slug='site-group-1')
sitegroup.save()
site = Site(name='Site 1', slug='site-1')
site.save()
location = Location(name='Location 1', slug='location-1', site=site)
location.save()
rack = Rack(name='Rack 1', site=site)
rack.save()
clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
clustertype.save()
clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
clustergroup.save()
cluster = Cluster(name='Cluster 1', type=clustertype)
cluster.save()
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0], description='A'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1], description='B'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2], description='C'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None),
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='A'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='B'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='C'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location, description='D'),
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack, description='E'),
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup, description='F'),
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster, description='G'),
VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
)
VLANGroup.objects.bulk_create(vlan_groups)
@@ -737,18 +768,32 @@ class VLANGroupTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': Region.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_sitegroup(self):
params = {'sitegroup': SiteGroup.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': Site.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_location(self):
params = {'location': Location.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_rack(self):
params = {'rack': Rack.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_clustergroup(self):
params = {'clustergroup': ClusterGroup.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_cluster(self):
params = {'cluster': Cluster.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class VLANTestCase(TestCase):
@@ -763,14 +808,21 @@ class VLANTestCase(TestCase):
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -782,9 +834,9 @@ class VLANTestCase(TestCase):
Role.objects.bulk_create(roles)
groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None),
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None),
)
VLANGroup.objects.bulk_create(groups)
@@ -832,6 +884,13 @@ class VLANTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -118,6 +118,10 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"RIR 6,rir-6,Sixth RIR",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Aggregate
@@ -187,6 +191,10 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Role 6,role-6,1000",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Prefix
@@ -306,18 +314,22 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
VLANGroup.objects.bulk_create([
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]),
])
cls.form_data = {
'name': 'VLAN Group X',
'slug': 'vlan-group-x',
'site': site.pk,
'site': sites[1].pk,
'description': 'A new VLAN group',
}
@@ -328,6 +340,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"VLAN Group 6,vlan-group-6,Sixth VLAN group",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VLAN
@@ -342,8 +358,8 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Site.objects.bulk_create(sites)
vlangroups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]),
)
VLANGroup.objects.bulk_create(vlangroups)

View File

@@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
@@ -17,6 +17,7 @@ urlpatterns = [
path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
path('vrfs/<int:pk>/journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}),
# Route targets
path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'),
@@ -28,11 +29,13 @@ urlpatterns = [
path('route-targets/<int:pk>/edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'),
path('route-targets/<int:pk>/delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'),
path('route-targets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}),
path('route-targets/<int:pk>/journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}),
# RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'),
path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<int:pk>/edit/', views.RIREditView.as_view(), name='rir_edit'),
path('rirs/<int:pk>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
@@ -48,11 +51,13 @@ urlpatterns = [
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
path('aggregates/<int:pk>/journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}),
# Roles
path('roles/', views.RoleListView.as_view(), name='role_list'),
path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<int:pk>/edit/', views.RoleEditView.as_view(), name='role_edit'),
path('roles/<int:pk>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
@@ -68,6 +73,7 @@ urlpatterns = [
path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
path('prefixes/<int:pk>/journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}),
path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
@@ -79,6 +85,7 @@ urlpatterns = [
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
path('ip-addresses/<int:pk>/journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}),
path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
@@ -88,6 +95,7 @@ urlpatterns = [
path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),
@@ -106,6 +114,7 @@ urlpatterns = [
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}),
# Services
path('services/', views.ServiceListView.as_view(), name='service_list'),
@@ -116,5 +125,6 @@ urlpatterns = [
path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
]

View File

@@ -30,6 +30,7 @@ class VRFView(generic.ObjectView):
def get_extra_context(self, request, instance):
prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count()
ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
import_targets_table = tables.RouteTargetTable(
instance.import_targets.prefetch_related('tenant'),
@@ -42,6 +43,7 @@ class VRFView(generic.ObjectView):
return {
'prefix_count': prefix_count,
'ipaddress_count': ipaddress_count,
'import_targets_table': import_targets_table,
'export_targets_table': export_targets_table,
}
@@ -162,6 +164,15 @@ class RIRBulkImportView(generic.BulkImportView):
table = tables.RIRTable
class RIRBulkEditView(generic.BulkEditView):
queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet
table = tables.RIRTable
form = forms.RIRBulkEditForm
class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
@@ -296,6 +307,13 @@ class RoleBulkImportView(generic.BulkImportView):
table = tables.RoleTable
class RoleBulkEditView(generic.BulkEditView):
queryset = Role.objects.all()
filterset = filters.RoleFilterSet
table = tables.RoleTable
form = forms.RoleBulkEditForm
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
table = tables.RoleTable
@@ -629,7 +647,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
#
class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
@@ -640,6 +658,7 @@ class VLANGroupListView(generic.ObjectListView):
class VLANGroupEditView(generic.ObjectEditView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm
template_name = 'ipam/vlangroup_edit.html'
class VLANGroupDeleteView(generic.ObjectDeleteView):
@@ -652,8 +671,17 @@ class VLANGroupBulkImportView(generic.BulkImportView):
table = tables.VLANGroupTable
class VLANGroupBulkEditView(generic.BulkEditView):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable
form = forms.VLANGroupBulkEditForm
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
@@ -766,6 +794,7 @@ class VLANVMInterfacesView(generic.ObjectView):
class VLANEditView(generic.ObjectEditView):
queryset = VLAN.objects.all()
model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
class VLANDeleteView(generic.ObjectDeleteView):

View File

@@ -6,11 +6,18 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CreateOnlyDefault
from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues
from extras.models import CustomField
from extras.models import CustomField, Tag
from utilities.utils import dict_to_filter_params
class ValidatedModelSerializer(serializers.ModelSerializer):
class BaseModelSerializer(serializers.ModelSerializer):
display = serializers.SerializerMethodField(read_only=True)
def get_display(self, obj):
return str(obj)
class ValidatedModelSerializer(BaseModelSerializer):
"""
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
@@ -70,19 +77,14 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields[field.name] = instance.cf.get(field.name)
class OrganizationalModelSerializer(CustomFieldModelSerializer):
pass
#
# Nested serializers
#
class NestedGroupModelSerializer(CustomFieldModelSerializer):
_depth = serializers.IntegerField(source='level', read_only=True)
class WritableNestedSerializer(serializers.ModelSerializer):
class WritableNestedSerializer(BaseModelSerializer):
"""
Returns a nested representation of an object on read, but accepts only a primary key on write.
"""
def to_internal_value(self, data):
if data is None:
@@ -128,5 +130,71 @@ class WritableNestedSerializer(serializers.ModelSerializer):
)
#
# Nested tags serialization
#
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color']
#
# Base model serializers
#
class OrganizationalModelSerializer(CustomFieldModelSerializer):
"""
Adds support for custom fields.
"""
pass
class PrimaryModelSerializer(CustomFieldModelSerializer):
"""
Adds support for custom fields and tags.
"""
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', None)
instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
else:
instance.tags.clear()
return instance
class NestedGroupModelSerializer(CustomFieldModelSerializer):
"""
Extends OrganizationalModelSerializer to include MPTT support.
"""
_depth = serializers.IntegerField(source='level', read_only=True)
class BulkOperationSerializer(serializers.Serializer):
id = serializers.IntegerField()

View File

@@ -76,6 +76,8 @@ class BulkUpdateModelMixin:
data_list = []
for obj in objects:
data = update_data.get(obj.id)
if hasattr(obj, 'snapshot'):
obj.snapshot()
serializer = self.get_serializer(obj, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
@@ -113,6 +115,8 @@ class BulkDestroyModelMixin:
def perform_bulk_destroy(self, objects):
with transaction.atomic():
for obj in objects:
if hasattr(obj, 'snapshot'):
obj.snapshot()
self.perform_destroy(obj)
@@ -127,6 +131,16 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
brief = False
brief_prefetch_fields = []
def get_object_with_snapshot(self):
"""
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
record the "before" data in the changelog.
"""
obj = super().get_object()
if hasattr(obj, 'snapshot'):
obj.snapshot()
return obj
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
@@ -221,6 +235,11 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
except ObjectDoesNotExist:
raise PermissionDenied()
def update(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
@@ -234,6 +253,11 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
except ObjectDoesNotExist:
raise PermissionDenied()
def destroy(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().destroy(request, *args, **kwargs)
def perform_destroy(self, instance):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')

View File

@@ -6,12 +6,12 @@ from circuits.filters import CircuitFilterSet, ProviderFilterSet
from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet,
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
SiteFilterSet, VirtualChassisFilterSet,
)
from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, RackGroup, Site, VirtualChassis
from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis
from dcim.tables import (
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
VirtualChassisTable,
)
from ipam.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
@@ -55,22 +55,22 @@ SEARCH_TYPES = OrderedDict((
'url': 'dcim:site_list',
}),
('rack', {
'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'),
'filterset': RackFilterSet,
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackgroup', {
'queryset': RackGroup.objects.add_related_count(
RackGroup.objects.all(),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.all(),
Rack,
'group',
'location',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': RackGroupFilterSet,
'table': RackGroupTable,
'url': 'dcim:rackgroup_list',
'filterset': LocationFilterSet,
'table': LocationTable,
'url': 'dcim:location_list',
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(

View File

@@ -11,7 +11,7 @@ OBJ_TYPE_CHOICES = (
('DCIM', (
('site', 'Sites'),
('rack', 'Racks'),
('rackgroup', 'Rack Groups'),
('location', 'Locations'),
('devicetype', 'Device Types'),
('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'),

View File

@@ -1,17 +1,20 @@
import logging
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from extras.choices import ObjectChangeActionChoices
from utilities.mptt import TreeManager
from utilities.utils import serialize_object
__all__ = (
'BigIDModel',
'ChangeLoggingMixin',
'CustomFieldsMixin',
'ChangeLoggedModel',
'NestedGroupModel',
'OrganizationalModel',
'PrimaryModel',
@@ -40,18 +43,32 @@ class ChangeLoggingMixin(models.Model):
class Meta:
abstract = True
def to_objectchange(self, action):
def snapshot(self):
"""
Save a snapshot of the object's current state in preparation for modification.
"""
logger = logging.getLogger('netbox')
logger.debug(f"Taking a snapshot of {self}")
self._prechange_snapshot = serialize_object(self)
def to_objectchange(self, action, related_object=None):
"""
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
by ChangeLoggingMiddleware.
"""
from extras.models import ObjectChange
return ObjectChange(
objectchange = ObjectChange(
changed_object=self,
related_object=related_object,
object_repr=str(self),
action=action,
object_data=serialize_object(self)
action=action
)
if hasattr(self, '_prechange_snapshot'):
objectchange.prechange_data = self._prechange_snapshot
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
objectchange.postchange_data = serialize_object(self)
return objectchange
class CustomFieldsMixin(models.Model):
@@ -121,12 +138,26 @@ class BigIDModel(models.Model):
abstract = True
class ChangeLoggedModel(ChangeLoggingMixin, BigIDModel):
"""
Base model for all objects which support change logging.
"""
class Meta:
abstract = True
class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel):
"""
Primary models represent real objects within the infrastructure being modeled.
"""
# TODO
# tags = TaggableManager(through=TaggedItem)
journal_entries = GenericRelation(
to='extras.JournalEntry',
object_id_field='assigned_object_id',
content_type_field='assigned_object_type'
)
tags = TaggableManager(
through='extras.TaggedItem'
)
class Meta:
abstract = True
@@ -164,16 +195,6 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTMo
def __str__(self):
return self.name
def to_objectchange(self, action):
# Remove MPTT-internal fields
from extras.models import ObjectChange
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
)
class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel):
"""

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.10.6-dev'
VERSION = '2.11-beta1'
# Hostname
HOSTNAME = platform.node()
@@ -29,6 +29,12 @@ if platform.python_version_tuple() < ('3', '6'):
raise RuntimeError(
"NetBox requires Python 3.6 or higher (current: Python {})".format(platform.python_version())
)
# TODO: Remove in NetBox v2.12
if platform.python_version_tuple() < ('3', '7'):
warnings.warn(
"Support for Python 3.6 will be dropped in NetBox v2.12. Please upgrade to Python 3.7 or later at your "
"earliest convenience."
)
#
@@ -429,7 +435,7 @@ CACHEOPS = {
'circuits.*': {'ops': 'all'},
'dcim.inventoryitem': None, # MPTT models are exempt due to raw SQL
'dcim.region': None, # MPTT models are exempt due to raw SQL
'dcim.rackgroup': None, # MPTT models are exempt due to raw SQL
'dcim.location': None, # MPTT models are exempt due to raw SQL
'dcim.*': {'ops': 'all'},
'ipam.*': {'ops': 'all'},
'extras.*': {'ops': 'all'},

View File

@@ -15,6 +15,7 @@ from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
from django.views.generic import View
from django_tables2 import RequestConfig
from django_tables2.export import TableExport
from extras.models import CustomField, ExportTemplate
from utilities.error_handlers import handle_protectederror
@@ -137,32 +138,35 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
if self.filterset:
self.queryset = self.filterset(request.GET, self.queryset).qs
# Check for export template rendering
if request.GET.get('export'):
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
try:
return et.render_to_response(self.queryset)
except Exception as e:
messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
# Check for export rendering (except for table-based)
if 'export' in request.GET and request.GET['export'] != 'table':
# An export template has been specified
if request.GET['export']:
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
try:
return et.render_to_response(self.queryset)
except Exception as e:
messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
)
)
)
# Check for YAML export support
elif 'export' in request.GET and hasattr(model, 'to_yaml'):
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Check for YAML export support
elif hasattr(model, 'to_yaml'):
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Fall back to built-in CSV formatting if export requested but no template specified
elif 'export' in request.GET and hasattr(model, 'to_csv'):
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Fall back to built-in CSV formatting if export requested but no template specified
elif 'export' in request.GET and hasattr(model, 'to_csv'):
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Compile a dictionary indicating which permissions are available to the current user for this model
permissions = {}
@@ -175,6 +179,22 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
# Handle table-based export
if request.GET.get('export') == 'table':
exclude_columns = {'pk'}
exclude_columns.update({
col for col in table.base_columns if col not in table.visible_columns
})
exporter = TableExport(
export_format=TableExport.CSV,
table=table,
exclude_columns=exclude_columns,
dataset_kwargs={},
)
return exporter.response(
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
@@ -218,11 +238,18 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
def get_object(self, kwargs):
# Look up an existing object by slug or PK, if provided.
if 'slug' in kwargs:
return get_object_or_404(self.queryset, slug=kwargs['slug'])
obj = get_object_or_404(self.queryset, slug=kwargs['slug'])
elif 'pk' in kwargs:
return get_object_or_404(self.queryset, pk=kwargs['pk'])
obj = get_object_or_404(self.queryset, pk=kwargs['pk'])
# Otherwise, return a new instance.
return self.queryset.model()
else:
return self.queryset.model()
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
return obj
def alter_obj(self, obj, request, url_args, url_kwargs):
# Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
@@ -328,9 +355,15 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
def get_object(self, kwargs):
# Look up object by slug if one has been provided. Otherwise, use PK.
if 'slug' in kwargs:
return get_object_or_404(self.queryset, slug=kwargs['slug'])
obj = get_object_or_404(self.queryset, slug=kwargs['slug'])
else:
return get_object_or_404(self.queryset, pk=kwargs['pk'])
obj = get_object_or_404(self.queryset, pk=kwargs['pk'])
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
return obj
def get(self, request, **kwargs):
obj = self.get_object(kwargs)
@@ -771,6 +804,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
updated_objects = []
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
# Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields:
@@ -898,6 +935,11 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
with transaction.atomic():
renamed_pks = []
for obj in selected_objects:
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
find = form.cleaned_data['find']
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
@@ -986,14 +1028,19 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Delete objects
queryset = self.queryset.filter(pk__in=pk_list)
deleted_count = queryset.count()
try:
deleted_count = queryset.delete()[1][model._meta.label]
for obj in queryset:
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
obj.delete()
except ProtectedError as e:
logger.info("Caught ProtectedError while attempting to delete objects")
handle_protectederror(queryset, request, e)
return redirect(self.get_return_url(request))
msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
logger.info(msg)
messages.success(request, msg)
return redirect(self.get_return_url(request))

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