Merge branch 'develop' into fix/generic_prefetch_4.2

This commit is contained in:
Andrey Tikhonov
2025-03-06 16:05:25 +01:00
1032 changed files with 253959 additions and 331699 deletions

View File

@@ -9,6 +9,8 @@ urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('notifications/', views.NotificationListView.as_view(), name='notifications'),
path('subscriptions/', views.SubscriptionListView.as_view(), name='subscriptions'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),

View File

@@ -19,8 +19,10 @@ from django.views.generic import View
from social_core.backends.utils import load_backends
from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from core.models import ObjectChange
from core.tables import ObjectChangeTable
from extras.models import Bookmark
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
@@ -44,10 +46,20 @@ class LoginView(View):
return super().dispatch(*args, **kwargs)
def gen_auth_data(self, name, url, params):
display_name, icon_name = get_auth_backend_display(name)
display_name, icon_source = get_auth_backend_display(name)
icon_name = None
icon_img = None
if icon_source:
if '://' in icon_source:
icon_img = icon_source
else:
icon_name = icon_source
return {
'display_name': display_name,
'icon_name': icon_name,
'icon_img': icon_img,
'url': f'{url}?{urlencode(params)}',
}
@@ -99,15 +111,21 @@ class LoginView(View):
# Authenticate user
auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated")
messages.success(request, f"Logged in as {request.user}.")
messages.success(request, _("Logged in as {user}.").format(user=request.user))
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
request.user.config = get_config()
UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
response = self.redirect_to_next(request, logger)
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
return response
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
@@ -143,11 +161,12 @@ class LogoutView(View):
username = request.user
auth_logout(request)
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
messages.info(request, _("You have logged out."))
# Delete session key cookie (if set) upon logout
# Delete session key & language cookies (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response
@@ -199,7 +218,7 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
@@ -217,7 +236,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
def get(self, request):
# LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
messages.warning(request, _("LDAP-authenticated user credentials cannot be changed within NetBox."))
return redirect('account:profile')
form = PasswordChangeForm(user=request.user)
@@ -232,7 +251,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.")
messages.success(request, _("Your password has been changed successfully."))
return redirect('account:profile')
return render(request, self.template_name, {
@@ -258,6 +277,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
}
#
# Notifications & subscriptions
#
class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
table = NotificationTable
template_name = 'account/notifications.html'
def get_queryset(self, request):
return request.user.notifications.all()
def get_extra_context(self, request):
return {
'active_tab': 'notifications',
}
class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
table = SubscriptionTable
template_name = 'account/subscriptions.html'
def get_queryset(self, request):
return request.user.subscriptions.all()
def get_extra_context(self, request):
return {
'active_tab': 'subscriptions',
}
#
# User views for token management
#

View File

@@ -1,88 +0,0 @@
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from circuits.models import *
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',
'NestedCircuitTerminationSerializer',
'NestedCircuitTypeSerializer',
'NestedProviderNetworkSerializer',
'NestedProviderSerializer',
'NestedProviderAccountSerializer',
]
#
# Provider networks
#
class NestedProviderNetworkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
class Meta:
model = ProviderNetwork
fields = ['id', 'url', 'display', 'name']
#
# Providers
#
@extend_schema_serializer(
exclude_fields=('circuit_count',),
)
class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = RelatedObjectCountField('circuits')
class Meta:
model = Provider
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
#
# Provider Accounts
#
class NestedProviderAccountSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
class Meta:
model = ProviderAccount
fields = ['id', 'url', 'display', 'name', 'account']
#
# Circuits
#
@extend_schema_serializer(
exclude_fields=('circuit_count',),
)
class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = RelatedObjectCountField('circuits')
class Meta:
model = CircuitType
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
class NestedCircuitSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta:
model = Circuit
fields = ['id', 'url', 'display', 'cid']
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied']

View File

@@ -1,3 +1,2 @@
from .serializers_.providers import *
from .serializers_.circuits import *
from .nested_serializers import *

View File

@@ -1,24 +1,35 @@
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import Circuit, CircuitTermination, CircuitType
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import (
Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
VirtualCircuitTermination, VirtualCircuitType,
)
from dcim.api.serializers_.device_components import InterfaceSerializer
from dcim.api.serializers_.cables import CabledObjectSerializer
from dcim.api.serializers_.sites import SiteSerializer
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
__all__ = (
'CircuitSerializer',
'CircuitGroupAssignmentSerializer',
'CircuitGroupSerializer',
'CircuitTerminationSerializer',
'CircuitTypeSerializer',
'VirtualCircuitSerializer',
'VirtualCircuitTerminationSerializer',
'VirtualCircuitTypeSerializer',
)
class CircuitTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
# Related object counts
circuit_count = RelatedObjectCountField('circuits')
@@ -26,56 +37,184 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
class Meta:
model = CircuitType
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = SiteSerializer(nested=True, allow_null=True)
provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
),
allow_null=True,
required=False,
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'description',
'id', 'url', 'display_url', 'display', 'termination_type', 'termination_id', 'termination', 'port_speed',
'upstream_speed', 'xconnect_id', 'description',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
circuit_count = RelatedObjectCountField('assignments')
class Meta:
model = CircuitGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant',
'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count'
]
brief_fields = ('id', 'url', 'display', 'name')
class CircuitGroupAssignmentSerializer_(NetBoxModelSerializer):
"""
Base serializer for group assignments under CircuitSerializer.
"""
group = CircuitGroupSerializer(nested=True)
priority = ChoiceField(choices=CircuitPriorityChoices, allow_blank=True, required=False)
class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'priority', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'priority')
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = CircuitTypeSerializer(nested=True)
distance_unit = ChoiceField(choices=DistanceUnitChoices, allow_blank=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
assignments = CircuitGroupAssignmentSerializer_(nested=True, many=True, required=False)
class Meta:
model = Circuit
fields = [
'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date',
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
'install_date', 'termination_date', 'commit_rate', 'description', 'distance', 'distance_unit',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'assignments',
]
brief_fields = ('id', 'url', 'display', 'cid', 'description')
brief_fields = ('id', 'url', 'display', 'provider', 'cid', 'description')
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = CircuitSerializer(nested=True)
site = SiteSerializer(nested=True, required=False, allow_null=True)
provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
),
allow_null=True,
required=False,
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'termination_type', 'termination_id',
'termination', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected',
'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated',
'_occupied',
]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
member_type = ContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
)
member = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority', 'tags',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_member(self, obj):
if obj.member_id is None:
return None
serializer = get_serializer_for_model(obj.member)
context = {'request': self.context['request']}
return serializer(obj.member, nested=True, context=context).data
class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
# Related object counts
virtual_circuit_count = RelatedObjectCountField('virtual_circuits')
class Meta:
model = VirtualCircuitType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'virtual_circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
class VirtualCircuitSerializer(NetBoxModelSerializer):
provider_network = ProviderNetworkSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
type = VirtualCircuitTypeSerializer(nested=True)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
class Meta:
model = VirtualCircuit
fields = [
'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'type', 'status',
'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')
class VirtualCircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
virtual_circuit = VirtualCircuitSerializer(nested=True)
role = ChoiceField(choices=VirtualCircuitTerminationRoleChoices, required=False)
interface = InterfaceSerializer(nested=True)
class Meta:
model = VirtualCircuitTermination
fields = [
'id', 'url', 'display_url', 'display', 'virtual_circuit', 'role', 'interface', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'virtual_circuit', 'role', 'interface', 'description')

View File

@@ -0,0 +1,13 @@
from circuits.models import ProviderAccount
from netbox.api.serializers import WritableNestedSerializer
__all__ = (
'NestedProviderAccountSerializer',
)
class NestedProviderAccountSerializer(WritableNestedSerializer):
class Meta:
model = ProviderAccount
fields = ['id', 'url', 'display_url', 'display', 'name', 'account']

View File

@@ -5,7 +5,7 @@ from ipam.api.serializers_.asns import ASNSerializer
from ipam.models import ASN
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
from ..nested_serializers import *
from .nested import NestedProviderAccountSerializer
__all__ = (
'ProviderAccountSerializer',
@@ -15,7 +15,6 @@ __all__ = (
class ProviderSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
accounts = SerializedPKRelatedField(
queryset=ProviderAccount.objects.all(),
serializer=NestedProviderAccountSerializer,
@@ -36,34 +35,32 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta:
model = Provider
fields = [
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'comments',
'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class ProviderAccountSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
provider = ProviderSerializer(nested=True)
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta:
model = ProviderAccount
fields = [
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
class ProviderNetworkSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
provider = ProviderSerializer(nested=True)
class Meta:
model = ProviderNetwork
fields = [
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -14,6 +14,13 @@ router.register('provider-networks', views.ProviderNetworkViewSet)
router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet)
router.register('circuit-groups', views.CircuitGroupViewSet)
router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
# Virtual circuits
router.register('virtual-circuits', views.VirtualCircuitViewSet)
router.register('virtual-circuit-types', views.VirtualCircuitTypeViewSet)
router.register('virtual-circuit-terminations', views.VirtualCircuitTerminationViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

View File

@@ -55,6 +55,26 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
filterset_class = filtersets.CircuitTerminationFilterSet
#
# Circuit Groups
#
class CircuitGroupViewSet(NetBoxModelViewSet):
queryset = CircuitGroup.objects.all()
serializer_class = serializers.CircuitGroupSerializer
filterset_class = filtersets.CircuitGroupFilterSet
#
# Circuit Group Assignments
#
class CircuitGroupAssignmentViewSet(NetBoxModelViewSet):
queryset = CircuitGroupAssignment.objects.all()
serializer_class = serializers.CircuitGroupAssignmentSerializer
filterset_class = filtersets.CircuitGroupAssignmentFilterSet
#
# Provider accounts
#
@@ -73,3 +93,33 @@ class ProviderNetworkViewSet(NetBoxModelViewSet):
queryset = ProviderNetwork.objects.all()
serializer_class = serializers.ProviderNetworkSerializer
filterset_class = filtersets.ProviderNetworkFilterSet
#
# Virtual circuit types
#
class VirtualCircuitTypeViewSet(NetBoxModelViewSet):
queryset = VirtualCircuitType.objects.all()
serializer_class = serializers.VirtualCircuitTypeSerializer
filterset_class = filtersets.VirtualCircuitTypeFilterSet
#
# Virtual circuits
#
class VirtualCircuitViewSet(NetBoxModelViewSet):
queryset = VirtualCircuit.objects.all()
serializer_class = serializers.VirtualCircuitSerializer
filterset_class = filtersets.VirtualCircuitFilterSet
#
# Virtual circuit terminations
#
class VirtualCircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = VirtualCircuitTermination.objects.all()
serializer_class = serializers.VirtualCircuitTerminationSerializer
filterset_class = filtersets.VirtualCircuitTerminationFilterSet

View File

@@ -7,7 +7,7 @@ class CircuitsConfig(AppConfig):
def ready(self):
from netbox.models.features import register_models
from . import signals, search
from . import signals, search # noqa: F401
# Register models
register_models(*self.get_models())

View File

@@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]
@@ -69,6 +71,40 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]
class CircuitPriorityChoices(ChoiceSet):
key = 'CircuitGroupAssignment.priority'
PRIORITY_PRIMARY = 'primary'
PRIORITY_SECONDARY = 'secondary'
PRIORITY_TERTIARY = 'tertiary'
PRIORITY_INACTIVE = 'inactive'
CHOICES = [
(PRIORITY_PRIMARY, _('Primary')),
(PRIORITY_SECONDARY, _('Secondary')),
(PRIORITY_TERTIARY, _('Tertiary')),
(PRIORITY_INACTIVE, _('Inactive')),
]
#
# Virtual circuits
#
class VirtualCircuitTerminationRoleChoices(ChoiceSet):
ROLE_PEER = 'peer'
ROLE_HUB = 'hub'
ROLE_SPOKE = 'spoke'
CHOICES = [
(ROLE_PEER, _('Peer'), 'green'),
(ROLE_HUB, _('Hub'), 'blue'),
(ROLE_SPOKE, _('Spoke'), 'orange'),
]

View File

@@ -0,0 +1,12 @@
from django.db.models import Q
# models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork',
)
CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS = Q(
app_label='circuits',
model__in=['circuit', 'virtualcircuit']
)

View File

@@ -1,60 +1,68 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup
from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from .choices import *
from .models import *
__all__ = (
'CircuitFilterSet',
'CircuitGroupAssignmentFilterSet',
'CircuitGroupFilterSet',
'CircuitTerminationFilterSet',
'CircuitTypeFilterSet',
'ProviderNetworkFilterSet',
'ProviderAccountFilterSet',
'ProviderFilterSet',
'VirtualCircuitFilterSet',
'VirtualCircuitTerminationFilterSet',
'VirtualCircuitTypeFilterSet',
)
class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region',
field_name='circuits__terminations___region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region',
field_name='circuits__terminations___region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group',
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',
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',
field_name='circuits__terminations___site',
queryset=Site.objects.all(),
label=_('Site'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site__slug',
field_name='circuits__terminations___site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
@@ -171,7 +179,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
label=_('Provider account (account)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__provider_network',
field_name='terminations___provider_network',
queryset=ProviderNetwork.objects.all(),
label=_('Provider network (ID)'),
)
@@ -191,37 +199,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region',
field_name='terminations___region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region',
field_name='terminations___region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
field_name='terminations___site_group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
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',
field_name='terminations___site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug',
field_name='terminations___site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
@@ -237,7 +245,9 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class Meta:
model = Circuit
fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate')
fields = (
'id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
)
def search(self, queryset, name, value):
if not value.strip():
@@ -261,26 +271,79 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=Circuit.objects.all(),
label=_('Circuit'),
)
termination_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
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(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
field_name='_provider_network',
label=_('ProviderNetwork (ID)'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider_id',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
class Meta:
model = CircuitTermination
fields = (
'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected',
'pp_info', 'cable_end',
'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
'mark_connected', 'pp_info', 'cable_end',
)
def search(self, queryset, name, value):
@@ -292,3 +355,231 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
Q(pp_info__icontains=value) |
Q(description__icontains=value)
).distinct()
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta:
model = CircuitGroup
fields = ('id', 'name', 'slug', 'description')
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
member_type = ContentTypeFilter()
circuit = MultiValueCharFilter(
method='filter_circuit',
field_name='cid',
label=_('Circuit (CID)'),
)
circuit_id = MultiValueNumberFilter(
method='filter_circuit',
field_name='pk',
label=_('Circuit (ID)'),
)
virtual_circuit = MultiValueCharFilter(
method='filter_virtual_circuit',
field_name='cid',
label=_('Virtual circuit (CID)'),
)
virtual_circuit_id = MultiValueNumberFilter(
method='filter_virtual_circuit',
field_name='pk',
label=_('Virtual circuit (ID)'),
)
provider = MultiValueCharFilter(
method='filter_provider',
field_name='slug',
label=_('Provider (name)'),
)
provider_id = MultiValueNumberFilter(
method='filter_provider',
field_name='pk',
label=_('Provider (ID)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(),
label=_('Circuit group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=CircuitGroup.objects.all(),
to_field_name='slug',
label=_('Circuit group (slug)'),
)
class Meta:
model = CircuitGroupAssignment
fields = ('id', 'member_id', 'priority')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(member__cid__icontains=value) |
Q(group__name__icontains=value)
)
def filter_circuit(self, queryset, name, value):
circuits = Circuit.objects.filter(**{f'{name}__in': value})
if not circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
)
)
def filter_virtual_circuit(self, queryset, name, value):
virtual_circuits = VirtualCircuit.objects.filter(**{f'{name}__in': value})
if not virtual_circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)
def filter_provider(self, queryset, name, value):
providers = Provider.objects.filter(**{f'{name}__in': value})
if not providers.exists():
return queryset.none()
circuits = Circuit.objects.filter(provider__in=providers)
virtual_circuits = VirtualCircuit.objects.filter(provider_network__provider__in=providers)
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
) |
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta:
model = VirtualCircuitType
fields = ('id', 'name', 'slug', 'color', 'description')
class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account',
queryset=ProviderAccount.objects.all(),
label=_('Provider account (ID)'),
)
provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='provider_account__account',
queryset=Provider.objects.all(),
to_field_name='account',
label=_('Provider account (account)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
label=_('Provider network (ID)'),
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuitType.objects.all(),
label=_('Virtual circuit type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=VirtualCircuitType.objects.all(),
to_field_name='slug',
label=_('Virtual circuit type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices,
null_value=None
)
class Meta:
model = VirtualCircuit
fields = ('id', 'cid', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(cid__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
).distinct()
class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualCircuit.objects.all(),
label=_('Virtual circuit'),
)
role = django_filters.MultipleChoiceFilter(
choices=VirtualCircuitTerminationRoleChoices,
null_value=None
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_network__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
provider_account_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account',
queryset=ProviderAccount.objects.all(),
label=_('Provider account (ID)'),
)
provider_account = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_circuit__provider_account__account',
queryset=ProviderAccount.objects.all(),
to_field_name='account',
label=_('Provider account (account)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
field_name='virtual_circuit__provider_network',
label=_('Provider network (ID)'),
)
interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(),
field_name='interface',
label=_('Interface (ID)'),
)
class Meta:
model = VirtualCircuitTermination
fields = ('id', 'interface_id', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(virtual_circuit__cid__icontains=value) |
Q(description__icontains=value)
).distinct()

View File

@@ -1,22 +1,38 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.choices import (
CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices,
)
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import (
ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DatePicker, NumberWithOptions
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
__all__ = (
'CircuitBulkEditForm',
'CircuitGroupAssignmentBulkEditForm',
'CircuitGroupBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
'ProviderAccountBulkEditForm',
'ProviderNetworkBulkEditForm',
'VirtualCircuitBulkEditForm',
'VirtualCircuitTerminationBulkEditForm',
'VirtualCircuitTypeBulkEditForm',
)
@@ -156,6 +172,17 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
options=CircuitCommitRateChoices
)
)
distance = forms.DecimalField(
label=_('Distance'),
min_value=0,
required=False
)
distance_unit = forms.ChoiceField(
label=_('Distance unit'),
choices=add_blank_choice(DistanceUnitChoices),
required=False,
initial=''
)
description = forms.CharField(
label=_('Description'),
max_length=100,
@@ -167,8 +194,187 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = (
FieldSet('provider', 'type', 'status', 'description', name=_('Circuit')),
FieldSet('provider_account', 'install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')),
FieldSet('distance', 'distance_unit', name=_('Attributes')),
FieldSet('tenant', name=_('Tenancy')),
)
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',
)
class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
termination_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False,
label=_('Termination type')
)
termination = DynamicModelChoiceField(
label=_('Termination'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
port_speed = forms.IntegerField(
required=False,
label=_('Port speed (Kbps)'),
)
upstream_speed = forms.IntegerField(
required=False,
label=_('Upstream speed (Kbps)'),
)
mark_connected = forms.NullBooleanField(
label=_('Mark connected'),
required=False,
widget=BulkEditNullBooleanSelect
)
model = CircuitTermination
fieldsets = (
FieldSet(
'description',
'termination_type', 'termination',
'mark_connected', name=_('Circuit Termination')
),
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
)
nullable_fields = ('description', 'termination')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False
)
model = CircuitGroup
nullable_fields = (
'description', 'tenant',
)
class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
required=False
)
priority = forms.ChoiceField(
label=_('Priority'),
choices=add_blank_choice(CircuitPriorityChoices),
required=False
)
model = CircuitGroupAssignment
fieldsets = (
FieldSet('member', 'priority'),
)
nullable_fields = ('priority',)
class VirtualCircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
color = ColorField(
label=_('Color'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = VirtualCircuitType
fieldsets = (
FieldSet('color', 'description'),
)
nullable_fields = ('color', 'description')
class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
required=False
)
provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
required=False
)
type = DynamicModelChoiceField(
label=_('Type'),
queryset=VirtualCircuitType.objects.all(),
required=False
)
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices),
required=False,
initial=''
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
comments = CommentField()
model = VirtualCircuit
fieldsets = (
FieldSet('provider_network', 'provider_account', 'status', 'description', name=_('Virtual circuit')),
FieldSet('tenant', name=_('Tenancy')),
)
nullable_fields = (
'provider_account', 'tenant', 'description', 'comments',
)
class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
role = forms.ChoiceField(
label=_('Role'),
choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
required=False,
initial=''
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = VirtualCircuitTermination
fieldsets = (
FieldSet('role', 'description'),
)
nullable_fields = ('description',)

View File

@@ -1,21 +1,30 @@
from django import forms
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from django.utils.safestring import mark_safe
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from circuits.constants import *
from circuits.models import *
from dcim.models import Interface
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
__all__ = (
'CircuitImportForm',
'CircuitGroupAssignmentImportForm',
'CircuitGroupImportForm',
'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderAccountImportForm',
'ProviderNetworkImportForm',
'VirtualCircuitImportForm',
'VirtualCircuitTerminationImportForm',
'VirtualCircuitTerminationImportRelatedForm',
'VirtualCircuitTypeImportForm',
)
@@ -65,9 +74,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta:
model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm):
@@ -95,6 +101,12 @@ class CircuitImportForm(NetBoxModelImportForm):
choices=CircuitStatusChoices,
help_text=_('Operational status')
)
distance_unit = CSVChoiceField(
label=_('Distance unit'),
choices=DistanceUnitChoices,
required=False,
help_text=_('Distance unit')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -107,27 +119,162 @@ class CircuitImportForm(NetBoxModelImportForm):
model = Circuit
fields = [
'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date',
'commit_rate', 'description', 'comments', 'tags'
'commit_rate', 'distance', 'distance_unit', 'description', 'comments', 'tags'
]
class CircuitTerminationImportForm(forms.ModelForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
required=False
class BaseCircuitTerminationImportForm(forms.ModelForm):
circuit = CSVModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
to_field_name='cid',
)
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False
term_side = CSVChoiceField(
label=_('Termination'),
choices=CircuitTerminationSideChoices,
)
termination_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
required=False,
label=_('Termination type (app & model)')
)
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description'
]
labels = {
'termination_id': _('Termination ID'),
}
class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description',
'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'tags'
]
labels = {
'termination_id': _('Termination ID'),
}
class CircuitGroupImportForm(NetBoxModelImportForm):
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned tenant')
)
class Meta:
model = CircuitGroup
fields = ('name', 'slug', 'description', 'tenant', 'tags')
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
member_type = CSVContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
label=_('Circuit type (app & model)')
)
priority = CSVChoiceField(
label=_('Priority'),
choices=CircuitPriorityChoices,
required=False
)
class Meta:
model = CircuitGroupAssignment
fields = ('member_type', 'member_id', 'group', 'priority')
class VirtualCircuitTypeImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = VirtualCircuitType
fields = ('name', 'slug', 'color', 'description', 'tags')
class VirtualCircuitImportForm(NetBoxModelImportForm):
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
help_text=_('The network to which this virtual circuit belongs')
)
provider_account = CSVModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
to_field_name='account',
help_text=_('Assigned provider account (if any)'),
required=False
)
type = CSVModelChoiceField(
label=_('Type'),
queryset=VirtualCircuitType.objects.all(),
to_field_name='name',
help_text=_('Type of virtual circuit')
)
status = CSVChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned tenant')
)
class Meta:
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'comments',
'tags',
]
class BaseVirtualCircuitTerminationImportForm(forms.ModelForm):
virtual_circuit = CSVModelChoiceField(
label=_('Virtual circuit'),
queryset=VirtualCircuit.objects.all(),
to_field_name='cid',
)
role = CSVChoiceField(
label=_('Role'),
choices=VirtualCircuitTerminationRoleChoices,
help_text=_('Operational role')
)
interface = CSVModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.all(),
to_field_name='pk',
)
class VirtualCircuitTerminationImportRelatedForm(BaseVirtualCircuitTerminationImportForm):
class Meta:
model = VirtualCircuitTermination
fields = [
'virtual_circuit', 'role', 'interface', 'description',
]
class VirtualCircuitTerminationImportForm(NetBoxModelImportForm, BaseVirtualCircuitTerminationImportForm):
class Meta:
model = VirtualCircuitTermination
fields = [
'virtual_circuit', 'role', 'interface', 'description', 'tags',
]

View File

@@ -1,22 +1,33 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.choices import (
CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices,
VirtualCircuitTerminationRoleChoices,
)
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from dcim.models import Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitFilterForm',
'CircuitGroupAssignmentFilterForm',
'CircuitGroupFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
'ProviderAccountFilterForm',
'ProviderNetworkFilterForm',
'VirtualCircuitFilterForm',
'VirtualCircuitTerminationFilterForm',
'VirtualCircuitTypeFilterForm',
)
@@ -111,7 +122,10 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type_id', 'status', 'install_date', 'termination_date', 'commit_rate', name=_('Attributes')),
FieldSet(
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
name=_('Attributes')
),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -185,4 +199,195 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
options=CircuitCommitRateChoices
)
)
distance = forms.DecimalField(
label=_('Distance'),
required=False,
)
distance_unit = forms.ChoiceField(
label=_('Distance unit'),
choices=add_blank_choice(DistanceUnitChoices),
required=False
)
tag = TagFilterField(model)
class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
model = CircuitTermination
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('circuit_id', 'term_side', name=_('Circuit')),
FieldSet('provider_id', name=_('Provider')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Termination')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
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,
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location')
)
circuit_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
)
term_side = forms.MultipleChoiceField(
label=_('Term Side'),
choices=CircuitTerminationSideChoices,
required=False
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
tag = TagFilterField(model)
class CircuitGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = CircuitGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
tag = TagFilterField(model)
class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
model = CircuitGroupAssignment
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'member_id', 'group_id', 'priority', name=_('Assignment')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
member_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
)
group_id = DynamicModelMultipleChoiceField(
queryset=CircuitGroup.objects.all(),
required=False,
label=_('Group')
)
priority = forms.MultipleChoiceField(
label=_('Priority'),
choices=CircuitPriorityChoices,
required=False
)
tag = TagFilterField(model)
class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = VirtualCircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
)
tag = TagFilterField(model)
color = ColorField(
label=_('Color'),
required=False
)
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = VirtualCircuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
provider_account_id = DynamicModelMultipleChoiceField(
queryset=ProviderAccount.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider account')
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network')
)
type_id = DynamicModelMultipleChoiceField(
queryset=VirtualCircuitType.objects.all(),
required=False,
label=_('Type')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
required=False
)
tag = TagFilterField(model)
class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
model = VirtualCircuitTermination
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('virtual_circuit_id', 'role', name=_('Virtual circuit')),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
)
virtual_circuit_id = DynamicModelMultipleChoiceField(
queryset=VirtualCircuit.objects.all(),
required=False,
label=_('Virtual circuit')
)
role = forms.MultipleChoiceField(
label=_('Role'),
choices=VirtualCircuitTerminationRoleChoices,
required=False
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
tag = TagFilterField(model)

View File

@@ -1,22 +1,37 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
from circuits.choices import (
CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices, VirtualCircuitTerminationRoleChoices,
)
from circuits.constants import *
from circuits.models import *
from dcim.models import Site
from dcim.models import Interface, Site
from ipam.models import ASN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import DatePicker, NumberWithOptions
from utilities.forms import get_field_value
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
__all__ = (
'CircuitForm',
'CircuitGroupAssignmentForm',
'CircuitGroupForm',
'CircuitTerminationForm',
'CircuitTypeForm',
'ProviderForm',
'ProviderAccountForm',
'ProviderNetworkForm',
'VirtualCircuitForm',
'VirtualCircuitTerminationForm',
'VirtualCircuitTypeForm',
)
@@ -43,7 +58,9 @@ class ProviderForm(NetBoxModelForm):
class ProviderAccountForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all()
queryset=Provider.objects.all(),
selector=True,
quick_add=True
)
comments = CommentField()
@@ -57,7 +74,9 @@ class ProviderAccountForm(NetBoxModelForm):
class ProviderNetworkForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all()
queryset=Provider.objects.all(),
selector=True,
quick_add=True
)
comments = CommentField()
@@ -90,7 +109,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
selector=True
selector=True,
quick_add=True
)
provider_account = DynamicModelChoiceField(
label=_('Provider account'),
@@ -101,12 +121,23 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
}
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all()
queryset=CircuitType.objects.all(),
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags', name=_('Circuit')),
FieldSet(
'provider',
'provider_account',
'cid',
'type',
'status',
InlineFields('distance', 'distance_unit', label=_('Distance')),
'description',
'tags',
name=_('Circuit')
),
FieldSet('install_date', 'termination_date', 'commit_rate', name=_('Service Parameters')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@@ -115,7 +146,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
model = Circuit
fields = [
'cid', 'type', 'provider', 'provider_account', 'status', 'install_date', 'termination_date', 'commit_rate',
'description', 'tenant_group', 'tenant', 'comments', 'tags',
'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'comments', 'tags',
]
widgets = {
'install_date': DatePicker(),
@@ -132,26 +163,24 @@ class CircuitTerminationForm(NetBoxModelForm):
queryset=Circuit.objects.all(),
selector=True
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
termination_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
widget=HTMXSelect(),
required=False,
selector=True
label=_('Termination type')
)
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
termination = DynamicModelChoiceField(
label=_('Termination'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
fieldsets = (
FieldSet(
'circuit', 'term_side', 'description', 'tags',
TabbedGroups(
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'termination_type', 'termination',
'mark_connected', name=_('Circuit Termination')
),
FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
@@ -160,7 +189,7 @@ class CircuitTerminationForm(NetBoxModelForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed',
'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'tags',
]
widgets = {
@@ -171,3 +200,189 @@ class CircuitTerminationForm(NetBoxModelForm):
options=CircuitTerminationPortSpeedChoices
),
}
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.termination:
initial['termination'] = instance.termination
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and termination_type_id != self.instance.termination_type_id:
self.initial['termination'] = None
def clean(self):
super().clean()
# Assign the selected termination (if any)
self.instance.termination = self.cleaned_data.get('termination')
class CircuitGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('Circuit Group')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = CircuitGroup
fields = [
'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
]
class CircuitGroupAssignmentForm(NetBoxModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=CircuitGroup.objects.all(),
)
member_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
widget=HTMXSelect(),
required=False,
label=_('Circuit type')
)
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
fieldsets = (
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
)
class Meta:
model = CircuitGroupAssignment
fields = [
'group', 'member_type', 'priority', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.member:
initial['member'] = instance.member
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if member_type_id := get_field_value(self, 'member_type'):
try:
model = ContentType.objects.get(pk=member_type_id).model_class()
self.fields['member'].queryset = model.objects.all()
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
self.fields['member'].disabled = False
self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance.pk and member_type_id != self.instance.member_type_id:
self.initial['member'] = None
def clean(self):
super().clean()
# Assign the selected circuit (if any)
self.instance.member = self.cleaned_data.get('member')
class VirtualCircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'tags'),
)
class Meta:
model = VirtualCircuitType
fields = [
'name', 'slug', 'color', 'description', 'tags',
]
class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
selector=True
)
provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
required=False
)
type = DynamicModelChoiceField(
queryset=VirtualCircuitType.objects.all(),
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet(
'provider_network', 'provider_account', 'cid', 'type', 'status', 'description', 'tags',
name=_('Virtual circuit'),
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'type', 'status', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
]
class VirtualCircuitTerminationForm(NetBoxModelForm):
virtual_circuit = DynamicModelChoiceField(
label=_('Virtual circuit'),
queryset=VirtualCircuit.objects.all(),
selector=True
)
role = forms.ChoiceField(
choices=VirtualCircuitTerminationRoleChoices,
widget=HTMXSelect(),
label=_('Role')
)
interface = DynamicModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.all(),
selector=True,
query_params={
'kind': 'virtual',
'virtual_circuit_termination_id': 'null',
},
context={
'parent': 'device',
}
)
fieldsets = (
FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
)
class Meta:
model = VirtualCircuitTermination
fields = [
'virtual_circuit', 'role', 'interface', 'description', 'tags',
]

View File

@@ -1,16 +1,20 @@
import strawberry
import strawberry_django
from circuits import filtersets, models
from circuits import filtersets, models
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = (
'CircuitTerminationFilter',
'CircuitFilter',
'CircuitGroupAssignmentFilter',
'CircuitGroupFilter',
'CircuitTerminationFilter',
'CircuitTypeFilter',
'ProviderFilter',
'ProviderAccountFilter',
'ProviderNetworkFilter',
'VirtualCircuitFilter',
'VirtualCircuitTerminationFilter',
'VirtualCircuitTypeFilter',
)
@@ -32,6 +36,18 @@ class CircuitTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroup, lookups=True)
@autotype_decorator(filtersets.CircuitGroupFilterSet)
class CircuitGroupFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet)
class CircuitGroupAssignmentFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.Provider, lookups=True)
@autotype_decorator(filtersets.ProviderFilterSet)
class ProviderFilter(BaseFilterMixin):
@@ -48,3 +64,21 @@ class ProviderAccountFilter(BaseFilterMixin):
@autotype_decorator(filtersets.ProviderNetworkFilterSet)
class ProviderNetworkFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet)
class VirtualCircuitTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuit, lookups=True)
@autotype_decorator(filtersets.VirtualCircuitFilterSet)
class VirtualCircuitFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet)
class VirtualCircuitTerminationFilter(BaseFilterMixin):
pass

View File

@@ -3,38 +3,40 @@ from typing import List
import strawberry
import strawberry_django
from circuits import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class CircuitsQuery:
@strawberry.field
def circuit(self, id: int) -> CircuitType:
return models.Circuit.objects.get(pk=id)
circuit: CircuitType = strawberry_django.field()
circuit_list: List[CircuitType] = strawberry_django.field()
@strawberry.field
def circuit_termination(self, id: int) -> CircuitTerminationType:
return models.CircuitTermination.objects.get(pk=id)
circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field()
@strawberry.field
def circuit_type(self, id: int) -> CircuitTypeType:
return models.CircuitType.objects.get(pk=id)
circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
@strawberry.field
def provider(self, id: int) -> ProviderType:
return models.Provider.objects.get(pk=id)
circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
provider: ProviderType = strawberry_django.field()
provider_list: List[ProviderType] = strawberry_django.field()
@strawberry.field
def provider_account(self, id: int) -> ProviderAccountType:
return models.ProviderAccount.objects.get(pk=id)
provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: List[ProviderAccountType] = strawberry_django.field()
@strawberry.field
def provider_network(self, id: int) -> ProviderNetworkType:
return models.ProviderNetwork.objects.get(pk=id)
provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: List[ProviderNetworkType] = strawberry_django.field()
virtual_circuit: VirtualCircuitType = strawberry_django.field()
virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field()
virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()

View File

@@ -1,4 +1,4 @@
from typing import Annotated, List
from typing import Annotated, List, Union
import strawberry
import strawberry_django
@@ -6,17 +6,22 @@ import strawberry_django
from circuits import models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, OrganizationalObjectType
from tenancy.graphql.types import TenantType
from .filters import *
__all__ = (
'CircuitTerminationType',
'CircuitType',
'CircuitGroupAssignmentType',
'CircuitGroupType',
'CircuitTypeType',
'ProviderType',
'ProviderAccountType',
'ProviderNetworkType',
'VirtualCircuitTerminationType',
'VirtualCircuitType',
'VirtualCircuitTypeType',
)
@@ -57,13 +62,21 @@ class ProviderNetworkType(NetBoxObjectType):
@strawberry_django.type(
models.CircuitTermination,
fields='__all__',
exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'),
filters=CircuitTerminationFilter
)
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
@strawberry_django.field
def termination(self) -> Annotated[Union[
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')],
], strawberry.union("CircuitTerminationTerminationType")] | None:
return self.termination
@strawberry_django.type(
@@ -91,3 +104,71 @@ class CircuitType(NetBoxObjectType, ContactsMixin):
tenant: TenantType | None
terminations: List[CircuitTerminationType]
@strawberry_django.type(
models.CircuitGroup,
fields='__all__',
filters=CircuitGroupFilter
)
class CircuitGroupType(OrganizationalObjectType):
tenant: TenantType | None
@strawberry_django.type(
models.CircuitGroupAssignment,
exclude=('member_type', 'member_id'),
filters=CircuitGroupAssignmentFilter
)
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
@strawberry_django.field
def member(self) -> Annotated[Union[
Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')],
Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')],
], strawberry.union("CircuitGroupAssignmentMemberType")] | None:
return self.member
@strawberry_django.type(
models.VirtualCircuitType,
fields='__all__',
filters=VirtualCircuitTypeFilter
)
class VirtualCircuitTypeType(OrganizationalObjectType):
color: str
virtual_circuits: List[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
models.VirtualCircuitTermination,
fields='__all__',
filters=VirtualCircuitTerminationFilter
)
class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
virtual_circuit: Annotated[
"VirtualCircuitType",
strawberry.lazy('circuits.graphql.types')
] = strawberry_django.field(select_related=["virtual_circuit"])
interface: Annotated[
"InterfaceType",
strawberry.lazy('dcim.graphql.types')
] = strawberry_django.field(select_related=["interface"])
@strawberry_django.type(
models.VirtualCircuit,
fields='__all__',
filters=VirtualCircuitFilter
)
class VirtualCircuitType(NetBoxObjectType):
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
provider_account: ProviderAccountType | None
type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
select_related=["type"]
)
tenant: TenantType | None
terminations: List[VirtualCircuitTerminationType]

View File

@@ -5,11 +5,9 @@ import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
replaces = [
('circuits', '0001_initial'),
@@ -98,7 +96,12 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider')),
(
'provider',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name='networks', to='circuits.provider'
),
),
],
options={
'ordering': ('provider', 'name'),

View File

@@ -4,7 +4,6 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('dcim', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
@@ -58,32 +57,56 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuittermination',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='contenttypes.contenttype',
),
),
migrations.AddField(
model_name='circuittermination',
name='cable',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.cable'),
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.cable'
),
),
migrations.AddField(
model_name='circuittermination',
name='circuit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.circuit'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.circuit'
),
),
migrations.AddField(
model_name='circuittermination',
name='provider_network',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.providernetwork'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuit_terminations',
to='circuits.providernetwork',
),
),
migrations.AddField(
model_name='circuittermination',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuit_terminations',
to='dcim.site',
),
),
migrations.AddField(
model_name='circuit',
name='provider',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provider'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provider'
),
),
migrations.AddField(
model_name='circuit',
@@ -93,26 +116,50 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuit',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.tenant'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuits',
to='tenancy.tenant',
),
),
migrations.AddField(
model_name='circuit',
name='termination_a',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'),
field=models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='circuits.circuittermination',
),
),
migrations.AddField(
model_name='circuit',
name='termination_z',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'),
field=models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='circuits.circuittermination',
),
),
migrations.AddField(
model_name='circuit',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.circuittype'),
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.circuittype'
),
),
migrations.AddConstraint(
model_name='providernetwork',
constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_provider_name'),
constraint=models.UniqueConstraint(
fields=('provider', 'name'), name='circuits_providernetwork_provider_name'
),
),
migrations.AlterUniqueTogether(
name='providernetwork',

View File

@@ -5,7 +5,6 @@ import utilities.json
class Migration(migrations.Migration):
replaces = [
('circuits', '0003_extend_tag_support'),
('circuits', '0004_rename_cable_peer'),
@@ -14,7 +13,7 @@ class Migration(migrations.Migration):
('circuits', '0034_created_datetimefield'),
('circuits', '0035_provider_asns'),
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
('circuits', '0037_new_cabling_models')
('circuits', '0037_new_cabling_models'),
]
dependencies = [

View File

@@ -6,13 +6,12 @@ import utilities.json
class Migration(migrations.Migration):
replaces = [
('circuits', '0038_cabling_cleanup'),
('circuits', '0039_unique_constraints'),
('circuits', '0040_provider_remove_deprecated_fields'),
('circuits', '0041_standardize_description_comments'),
('circuits', '0042_provideraccount')
('circuits', '0042_provideraccount'),
]
dependencies = [
@@ -51,11 +50,15 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='circuittermination',
constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'),
constraint=models.UniqueConstraint(
fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'
),
),
migrations.AddConstraint(
model_name='providernetwork',
constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'),
constraint=models.UniqueConstraint(
fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'
),
),
migrations.RemoveField(
model_name='provider',
@@ -84,12 +87,20 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('account', models.CharField(max_length=100)),
('name', models.CharField(blank=True, max_length=100)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='circuits.provider')),
(
'provider',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='circuits.provider'
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
@@ -98,11 +109,17 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='provideraccount',
constraint=models.UniqueConstraint(condition=models.Q(('name', ''), _negated=True), fields=('provider', 'name'), name='circuits_provideraccount_unique_provider_name'),
constraint=models.UniqueConstraint(
condition=models.Q(('name', ''), _negated=True),
fields=('provider', 'name'),
name='circuits_provideraccount_unique_provider_name',
),
),
migrations.AddConstraint(
model_name='provideraccount',
constraint=models.UniqueConstraint(fields=('provider', 'account'), name='circuits_provideraccount_unique_provider_account'),
constraint=models.UniqueConstraint(
fields=('provider', 'account'), name='circuits_provideraccount_unique_provider_account'
),
),
migrations.RemoveField(
model_name='provider',
@@ -111,7 +128,13 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuit',
name='provider_account',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.provideraccount'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuits',
to='circuits.provideraccount',
),
preserve_default=False,
),
migrations.AlterModelOptions(
@@ -120,6 +143,8 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='circuit',
constraint=models.UniqueConstraint(fields=('provider_account', 'cid'), name='circuits_circuit_unique_provideraccount_cid'),
constraint=models.UniqueConstraint(
fields=('provider_account', 'cid'), name='circuits_circuit_unique_provideraccount_cid'
),
),
]

View File

@@ -0,0 +1,87 @@
import django.db.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0043_circuittype_color'),
('extras', '0119_notifications'),
('tenancy', '0015_contactassignment_rename_content_type'),
]
operations = [
migrations.CreateModel(
name='CircuitGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
(
'tenant',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuit_groups',
to='tenancy.tenant',
),
),
],
options={
'verbose_name': 'Circuit group',
'verbose_name_plural': 'Circuit group',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='CircuitGroupAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
),
('priority', models.CharField(blank=True, max_length=50)),
(
'circuit',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='assignments',
to='circuits.circuit',
),
),
(
'group',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='assignments',
to='circuits.circuitgroup',
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'Circuit group assignment',
'verbose_name_plural': 'Circuit group assignments',
'ordering': ('group', 'circuit', 'priority', 'pk'),
},
),
migrations.AddConstraint(
model_name='circuitgroupassignment',
constraint=models.UniqueConstraint(
fields=('circuit', 'group'), name='circuits_circuitgroupassignment_unique_circuit_group'
),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.0.9 on 2024-09-26 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0044_circuit_groups'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='_abs_distance',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True),
),
migrations.AddField(
model_name='circuit',
name='distance',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='circuit',
name='distance_unit',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
def set_null_values(apps, schema_editor):
"""
Replace empty strings with null values.
"""
Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
Circuit.objects.filter(distance_unit='').update(distance_unit=None)
CircuitGroupAssignment.objects.filter(priority='').update(priority=None)
CircuitTermination.objects.filter(cable_end='').update(cable_end=None)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0045_circuit_distance'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='distance_unit',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='circuitgroupassignment',
name='priority',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='circuittermination',
name='cable_end',
field=models.CharField(blank=True, max_length=1, null=True),
),
migrations.RunPython(code=set_null_values, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,53 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_site_assignments(apps, schema_editor):
"""
Copy site ForeignKey values to the Termination GFK.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
Site = apps.get_model('dcim', 'Site')
CircuitTermination.objects.filter(site__isnull=False).update(
termination_type=ContentType.objects.get_for_model(Site), termination_id=models.F('site_id')
)
ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
CircuitTermination.objects.filter(provider_network__isnull=False).update(
termination_type=ContentType.objects.get_for_model(ProviderNetwork),
termination_id=models.F('provider_network_id'),
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0046_charfield_null_choices'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0193_poweroutlet_color'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='termination_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='termination_type',
field=models.ForeignKey(
blank=True,
limit_choices_to=models.Q(
('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork'))
),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
),
),
# Copy over existing site assignments
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,84 @@
# Generated by Django 5.0.9 on 2024-10-21 17:34
import django.db.models.deletion
from django.db import migrations, models
def populate_denormalized_fields(apps, schema_editor):
"""
Copy site ForeignKey values to the Termination GFK.
"""
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site')
for termination in terminations:
termination._region_id = termination.site.region_id
termination._site_group_id = termination.site.group_id
termination._site_id = termination.site_id
# Note: Location cannot be set prior to migration
CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'])
class Migration(migrations.Migration):
dependencies = [
('circuits', '0047_circuittermination__termination'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='_location',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.location',
),
),
migrations.AddField(
model_name='circuittermination',
name='_region',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.region',
),
),
migrations.AddField(
model_name='circuittermination',
name='_site',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.site',
),
),
migrations.AddField(
model_name='circuittermination',
name='_site_group',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_terminations',
to='dcim.sitegroup',
),
),
# Populate denormalized FK values
migrations.RunPython(code=populate_denormalized_fields, reverse_code=migrations.RunPython.noop),
# Delete the site ForeignKey
migrations.RemoveField(
model_name='circuittermination',
name='site',
),
migrations.RenameField(
model_name='circuittermination',
old_name='provider_network',
new_name='_provider_network',
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0048_circuitterminations_cached_relations'),
('dcim', '0197_natural_sort_collation'),
]
operations = [
migrations.AlterField(
model_name='provider',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='providernetwork',
name='name',
field=models.CharField(db_collation='natural_sort', max_length=100),
),
]

View File

@@ -0,0 +1,147 @@
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import utilities.fields
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('circuits', '0049_natural_ordering'),
('dcim', '0196_qinq_svlan'),
('extras', '0122_charfield_null_choices'),
('tenancy', '0016_charfield_null_choices'),
]
operations = [
migrations.CreateModel(
name='VirtualCircuitType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder
)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('color', utilities.fields.ColorField(blank=True, max_length=6)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'virtual circuit type',
'verbose_name_plural': 'virtual circuit types',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='VirtualCircuit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('cid', models.CharField(max_length=100)),
('status', models.CharField(default='active', max_length=50)),
(
'provider_account',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='virtual_circuits',
to='circuits.provideraccount',
),
),
(
'provider_network',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='virtual_circuits',
to='circuits.providernetwork',
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
(
'type',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='virtual_circuits',
to='circuits.virtualcircuittype'
)
),
(
'tenant',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='virtual_circuits',
to='tenancy.tenant',
),
),
],
options={
'verbose_name': 'circuit',
'verbose_name_plural': 'circuits',
'ordering': ['provider_network', 'provider_account', 'cid'],
},
),
migrations.CreateModel(
name='VirtualCircuitTermination',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
),
('role', models.CharField(default='peer', max_length=50)),
('description', models.CharField(blank=True, max_length=200)),
(
'interface',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name='virtual_circuit_termination',
to='dcim.interface',
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
(
'virtual_circuit',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='terminations',
to='circuits.virtualcircuit',
),
),
],
options={
'verbose_name': 'virtual circuit termination',
'verbose_name_plural': 'virtual circuit terminations',
'ordering': ['virtual_circuit', 'role', 'pk'],
},
),
migrations.AddConstraint(
model_name='virtualcircuit',
constraint=models.UniqueConstraint(
fields=('provider_network', 'cid'), name='circuits_virtualcircuit_unique_provider_network_cid'
),
),
migrations.AddConstraint(
model_name='virtualcircuit',
constraint=models.UniqueConstraint(
fields=('provider_account', 'cid'), name='circuits_virtualcircuit_unique_provideraccount_cid'
),
),
]

View File

@@ -0,0 +1,85 @@
import django.db.models.deletion
from django.db import migrations, models
def set_member_type(apps, schema_editor):
"""
Set member_type on any existing CircuitGroupAssignments to the content type for Circuit.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
CircuitGroupAssignment.objects.update(
member_type=ContentType.objects.get_for_model(Circuit)
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0050_virtual_circuits'),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.RemoveConstraint(
model_name='circuitgroupassignment',
name='circuits_circuitgroupassignment_unique_circuit_group',
),
migrations.AlterModelOptions(
name='circuitgroupassignment',
options={'ordering': ('group', 'member_type', 'member_id', 'priority', 'pk')},
),
# Change member_id to an integer field for the member GFK
migrations.RenameField(
model_name='circuitgroupassignment',
old_name='circuit',
new_name='member_id',
),
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_id',
field=models.PositiveBigIntegerField(),
),
# Add content type pointer for the member GFK
migrations.AddField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
related_name='+',
to='contenttypes.contenttype',
blank=True,
null=True
),
preserve_default=False,
),
# Populate member_type for any existing assignments
migrations.RunPython(code=set_member_type, reverse_code=migrations.RunPython.noop),
# Disallow null values for member_type
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
),
),
migrations.AddConstraint(
model_name='circuitgroupassignment',
constraint=models.UniqueConstraint(
fields=('member_type', 'member_id', 'group'),
name='circuits_circuitgroupassignment_unique_member_group'
),
),
]

View File

@@ -1,2 +1,3 @@
from .circuits import *
from .providers import *
from .virtual_circuits import *

View File

@@ -0,0 +1,23 @@
from django.utils.translation import gettext_lazy as _
from netbox.models import OrganizationalModel
from utilities.fields import ColorField
__all__ = (
'BaseCircuitType',
)
class BaseCircuitType(OrganizationalModel):
"""
Abstract base model to represent a type of physical or virtual circuit.
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
color = ColorField(
verbose_name=_('color'),
blank=True
)
class Meta:
abstract = True

View File

@@ -1,41 +1,41 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from circuits.constants import *
from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
from utilities.fields import ColorField
from netbox.models.mixins import DistanceMixin
from netbox.models.features import (
ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin,
)
from .base import BaseCircuitType
__all__ = (
'Circuit',
'CircuitGroup',
'CircuitGroupAssignment',
'CircuitTermination',
'CircuitType',
)
class CircuitType(OrganizationalModel):
class CircuitType(BaseCircuitType):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
color = ColorField(
verbose_name=_('color'),
blank=True
)
def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk])
class Meta:
ordering = ('name',)
verbose_name = _('circuit type')
verbose_name_plural = _('circuit types')
class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
@@ -59,7 +59,7 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
null=True
)
type = models.ForeignKey(
to='CircuitType',
to='circuits.CircuitType',
on_delete=models.PROTECT,
related_name='circuits'
)
@@ -111,6 +111,13 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
null=True
)
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='circuit'
)
clone_fields = (
'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description',
@@ -138,9 +145,6 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
def __str__(self):
return self.cid
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
def get_status_color(self):
return CircuitStatusChoices.colors.get(self.status)
@@ -151,6 +155,80 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
raise ValidationError({'provider_account': "The assigned account must belong to the assigned provider."})
class CircuitGroup(OrganizationalModel):
"""
An administrative grouping of Circuits.
"""
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='circuit_groups',
blank=True,
null=True
)
class Meta:
ordering = ('name',)
verbose_name = _('circuit group')
verbose_name_plural = _('circuit groups')
def __str__(self):
return self.name
class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
"""
Assignment of a physical or virtual circuit to a CircuitGroup with an optional priority.
"""
member_type = models.ForeignKey(
to='contenttypes.ContentType',
limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
member_id = models.PositiveBigIntegerField(
verbose_name=_('member ID')
)
member = GenericForeignKey(
ct_field='member_type',
fk_field='member_id'
)
group = models.ForeignKey(
to='circuits.CircuitGroup',
on_delete=models.CASCADE,
related_name='assignments'
)
priority = models.CharField(
verbose_name=_('priority'),
max_length=50,
choices=CircuitPriorityChoices,
blank=True,
null=True
)
prerequisite_models = (
'circuits.CircuitGroup',
)
class Meta:
ordering = ('group', 'member_type', 'member_id', 'priority', 'pk')
constraints = (
models.UniqueConstraint(
fields=('member_type', 'member_id', 'group'),
name='%(app_label)s_%(class)s_unique_member_group'
),
)
verbose_name = _('Circuit group assignment')
verbose_name_plural = _('Circuit group assignments')
def __str__(self):
if self.priority:
return f"{self.group} ({self.get_priority_display()})"
return str(self.group)
def get_absolute_url(self):
return reverse('circuits:circuitgroupassignment', args=[self.pk])
class CircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,
@@ -166,22 +244,24 @@ class CircuitTermination(
term_side = models.CharField(
max_length=1,
choices=CircuitTerminationSideChoices,
verbose_name=_('termination')
verbose_name=_('termination side')
)
site = models.ForeignKey(
to='dcim.Site',
termination_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
related_name='circuit_terminations',
limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
related_name='+',
blank=True,
null=True
)
provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
termination_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
termination = GenericForeignKey(
ct_field='termination_type',
fk_field='termination_id'
)
port_speed = models.PositiveIntegerField(
verbose_name=_('port speed (Kbps)'),
blank=True,
@@ -212,6 +292,43 @@ class CircuitTermination(
blank=True
)
# Cached associations to enable efficient filtering
_provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
blank=True,
null=True
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
_site_group = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
related_name='circuit_terminations',
blank=True,
null=True
)
class Meta:
ordering = ['circuit', 'term_side']
constraints = (
@@ -227,16 +344,41 @@ class CircuitTermination(
return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self):
return self.circuit.get_absolute_url()
return reverse('circuits:circuittermination', args=[self.pk])
def clean(self):
super().clean()
# Must define either site *or* provider network
if self.site is None and self.provider_network is None:
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
if self.site and self.provider_network:
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
if self.termination is None:
raise ValidationError(_("A circuit termination must attach to termination."))
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type:
termination_type = self.termination_type.model_class()
if termination_type == apps.get_model('dcim', 'region'):
self._region = self.termination
elif termination_type == apps.get_model('dcim', 'sitegroup'):
self._site_group = self.termination
elif termination_type == apps.get_model('dcim', 'site'):
self._region = self.termination.region
self._site_group = self.termination.group
self._site = self.termination
elif termination_type == apps.get_model('dcim', 'location'):
self._region = self.termination.site.region
self._site_group = self.termination.site.group
self._site = self.termination.site
self._location = self.termination
elif termination_type == apps.get_model('circuits', 'providernetwork'):
self._provider_network = self.termination
cache_related_objects.alters_data = True
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@@ -250,7 +392,7 @@ class CircuitTermination(
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
return CircuitTermination.objects.prefetch_related('site').get(
return CircuitTermination.objects.prefetch_related('termination').get(
circuit=self.circuit,
term_side=peer_side
)

View File

@@ -1,6 +1,5 @@
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel
@@ -22,7 +21,8 @@ class Provider(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
help_text=_('Full name of the provider')
help_text=_('Full name of the provider'),
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
@@ -45,9 +45,6 @@ class Provider(ContactsMixin, PrimaryModel):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:provider', args=[self.pk])
class ProviderAccount(ContactsMixin, PrimaryModel):
"""
@@ -91,9 +88,6 @@ class ProviderAccount(ContactsMixin, PrimaryModel):
return f'{self.account} ({self.name})'
return f'{self.account}'
def get_absolute_url(self):
return reverse('circuits:provideraccount', args=[self.pk])
class ProviderNetwork(PrimaryModel):
"""
@@ -102,7 +96,8 @@ class ProviderNetwork(PrimaryModel):
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100
max_length=100,
db_collation="natural_sort"
)
provider = models.ForeignKey(
to='circuits.Provider',
@@ -128,6 +123,3 @@ class ProviderNetwork(PrimaryModel):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:providernetwork', args=[self.pk])

View File

@@ -0,0 +1,191 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
from .base import BaseCircuitType
__all__ = (
'VirtualCircuit',
'VirtualCircuitTermination',
'VirtualCircuitType',
)
class VirtualCircuitType(BaseCircuitType):
"""
Like physical circuits, virtual circuits can be organized by their functional role. For example, a user might wish
to categorize virtual circuits by their technological nature or by product name.
"""
class Meta:
ordering = ('name',)
verbose_name = _('virtual circuit type')
verbose_name_plural = _('virtual circuit types')
class VirtualCircuit(PrimaryModel):
"""
A virtual connection between two or more endpoints, delivered across one or more physical circuits.
"""
cid = models.CharField(
max_length=100,
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
)
provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='virtual_circuits'
)
provider_account = models.ForeignKey(
to='circuits.ProviderAccount',
on_delete=models.PROTECT,
related_name='virtual_circuits',
blank=True,
null=True
)
type = models.ForeignKey(
to='circuits.VirtualCircuitType',
on_delete=models.PROTECT,
related_name='virtual_circuits'
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=CircuitStatusChoices,
default=CircuitStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='virtual_circuits',
blank=True,
null=True
)
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='virtual_circuit'
)
clone_fields = (
'provider_network', 'provider_account', 'status', 'tenant', 'description',
)
prerequisite_models = (
'circuits.ProviderNetwork',
'circuits.VirtualCircuitType',
)
class Meta:
ordering = ['provider_network', 'provider_account', 'cid']
constraints = (
models.UniqueConstraint(
fields=('provider_network', 'cid'),
name='%(app_label)s_%(class)s_unique_provider_network_cid'
),
models.UniqueConstraint(
fields=('provider_account', 'cid'),
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
verbose_name = _('virtual circuit')
verbose_name_plural = _('virtual circuits')
def __str__(self):
return self.cid
def get_status_color(self):
return CircuitStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if self.provider_account and self.provider_network.provider != self.provider_account.provider:
raise ValidationError({
'provider_account': "The assigned account must belong to the provider of the assigned network."
})
@property
def provider(self):
return self.provider_network.provider
class VirtualCircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,
TagsMixin,
ChangeLoggedModel
):
virtual_circuit = models.ForeignKey(
to='circuits.VirtualCircuit',
on_delete=models.CASCADE,
related_name='terminations'
)
role = models.CharField(
verbose_name=_('role'),
max_length=50,
choices=VirtualCircuitTerminationRoleChoices,
default=VirtualCircuitTerminationRoleChoices.ROLE_PEER
)
interface = models.OneToOneField(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='virtual_circuit_termination'
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
class Meta:
ordering = ['virtual_circuit', 'role', 'pk']
verbose_name = _('virtual circuit termination')
verbose_name_plural = _('virtual circuit terminations')
def __str__(self):
return f'{self.virtual_circuit}: {self.get_role_display()} termination'
def get_absolute_url(self):
return reverse('circuits:virtualcircuittermination', args=[self.pk])
def get_role_color(self):
return VirtualCircuitTerminationRoleChoices.colors.get(self.role)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.virtual_circuit
return objectchange
@property
def parent_object(self):
return self.virtual_circuit
@cached_property
def peer_terminations(self):
if self.role == VirtualCircuitTerminationRoleChoices.ROLE_PEER:
return self.virtual_circuit.terminations.exclude(pk=self.pk).filter(
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER
)
if self.role == VirtualCircuitTerminationRoleChoices.ROLE_HUB:
return self.virtual_circuit.terminations.filter(
role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE
)
if self.role == VirtualCircuitTerminationRoleChoices.ROLE_SPOKE:
return self.virtual_circuit.terminations.filter(
role=VirtualCircuitTerminationRoleChoices.ROLE_HUB
)
def clean(self):
super().clean()
if self.interface and not self.interface.is_virtual:
raise ValidationError("Virtual circuits may be terminated only to virtual interfaces.")

View File

@@ -13,6 +13,17 @@ class CircuitIndex(SearchIndex):
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
@register_search
class CircuitGroupIndex(SearchIndex):
model = models.CircuitGroup
fields = (
('name', 100),
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
class CircuitTerminationIndex(SearchIndex):
model = models.CircuitTermination
@@ -48,6 +59,7 @@ class ProviderIndex(SearchIndex):
display_attrs = ('description',)
@register_search
class ProviderAccountIndex(SearchIndex):
model = models.ProviderAccount
fields = (
@@ -68,3 +80,34 @@ class ProviderNetworkIndex(SearchIndex):
('comments', 5000),
)
display_attrs = ('provider', 'service_id', 'description')
@register_search
class VirtualCircuitIndex(SearchIndex):
model = models.VirtualCircuit
fields = (
('cid', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('provider', 'provider_network', 'provider_account', 'status', 'tenant', 'description')
@register_search
class VirtualCircuitTerminationIndex(SearchIndex):
model = models.VirtualCircuitTermination
fields = (
('description', 500),
)
display_attrs = ('virtual_circuit', 'role', 'description')
@register_search
class VirtualCircuitTypeIndex(SearchIndex):
model = models.VirtualCircuitType
fields = (
('name', 100),
('slug', 110),
('description', 500),
)
display_attrs = ('description',)

View File

@@ -1,3 +1,4 @@
from .circuits import *
from .columns import *
from .providers import *
from .virtual_circuits import *

View File

@@ -9,16 +9,17 @@ from netbox.tables import NetBoxTable, columns
from .columns import CommitRateColumn
__all__ = (
'CircuitGroupAssignmentTable',
'CircuitGroupTable',
'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable',
)
CIRCUITTERMINATION_LINK = """
{% if value.site %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
{% elif value.provider_network %}
<a href="{{ value.provider_network.get_absolute_url }}">{{ value.provider_network }}</a>
{% if value.termination %}
<a href="{{ value.termination.get_absolute_url }}">{{ value.termination }}</a>
{% endif %}
"""
@@ -41,9 +42,10 @@ class CircuitTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CircuitType
fields = (
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
@@ -59,32 +61,159 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn(
termination_a = columns.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side A')
)
termination_z = tables.TemplateColumn(
termination_z = columns.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side Z')
)
commit_rate = CommitRateColumn(
verbose_name=_('Commit Rate')
)
distance = columns.DistanceColumn()
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
verbose_name=_('Comments')
)
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)
assignments = columns.ManyToManyColumn(
verbose_name=_('Assignments'),
linkify_item=True
)
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'tenant_group',
'termination_a', 'termination_z', 'install_date', 'termination_date', 'commit_rate', 'description',
'comments', 'contacts', 'tags', 'created', 'last_updated',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'assignments',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
)
class CircuitTerminationTable(NetBoxTable):
circuit = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True,
accessor='circuit.provider'
)
term_side = tables.Column(
verbose_name=_('Side')
)
termination_type = columns.ContentTypeColumn(
verbose_name=_('Termination Type'),
)
termination = tables.Column(
verbose_name=_('Termination Point'),
linkify=True
)
# Termination types
site = tables.Column(
verbose_name=_('Site'),
linkify=True,
accessor='_site'
)
site_group = tables.Column(
verbose_name=_('Site Group'),
linkify=True,
accessor='_sitegroup'
)
region = tables.Column(
verbose_name=_('Region'),
linkify=True,
accessor='_region'
)
location = tables.Column(
verbose_name=_('Location'),
linkify=True,
accessor='_location'
)
provider_network = tables.Column(
verbose_name=_('Provider Network'),
linkify=True,
accessor='_provider_network'
)
class Meta(NetBoxTable.Meta):
model = CircuitTermination
fields = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region',
'site', 'location', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'created', 'last_updated', 'actions',
)
default_columns = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'description',
)
class CircuitGroupTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
circuit_group_assignment_count = columns.LinkedCountColumn(
viewname='circuits:circuitgroupassignment_list',
url_params={'group_id': 'pk'},
verbose_name=_('Circuits')
)
tags = columns.TagColumn(
url_name='circuits:circuitgroup_list'
)
class Meta(NetBoxTable.Meta):
model = CircuitGroup
fields = (
'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
class CircuitGroupAssignmentTable(NetBoxTable):
group = tables.Column(
verbose_name=_('Group'),
linkify=True
)
provider = tables.Column(
accessor='member__provider',
verbose_name=_('Provider'),
linkify=True
)
member_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
member = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
priority = tables.Column(
verbose_name=_('Priority'),
)
tags = columns.TagColumn(
url_name='circuits:circuitgroupassignment_list'
)
class Meta(NetBoxTable.Meta):
model = CircuitGroupAssignment
fields = (
'pk', 'id', 'group', 'provider', 'member_type', 'member', 'priority', 'created', 'last_updated', 'actions',
'tags',
)
default_columns = ('pk', 'group', 'provider', 'member_type', 'member', 'priority')

View File

@@ -25,7 +25,7 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list',
url_params={'account_id': 'pk'},
url_params={'provider_id': 'pk'},
verbose_name=_('Account Count')
)
asns = columns.ManyToManyColumn(

View File

@@ -0,0 +1,124 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from circuits.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
__all__ = (
'VirtualCircuitTable',
'VirtualCircuitTerminationTable',
'VirtualCircuitTypeTable',
)
class VirtualCircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True,
verbose_name=_('Name'),
)
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='circuits:virtualcircuittype_list'
)
virtual_circuit_count = columns.LinkedCountColumn(
viewname='circuits:virtualcircuit_list',
url_params={'type_id': 'pk'},
verbose_name=_('Circuits')
)
class Meta(NetBoxTable.Meta):
model = VirtualCircuitType
fields = (
'pk', 'id', 'name', 'virtual_circuit_count', 'color', 'description', 'slug', 'tags', 'created',
'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'virtual_circuit_count', 'color', 'description')
class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name=_('Circuit ID')
)
provider = tables.Column(
accessor=tables.A('provider_network__provider'),
verbose_name=_('Provider'),
linkify=True
)
provider_network = tables.Column(
linkify=True,
verbose_name=_('Provider network')
)
provider_account = tables.Column(
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_count = columns.LinkedCountColumn(
viewname='circuits:virtualcircuittermination_list',
url_params={'virtual_circuit_id': 'pk'},
verbose_name=_('Terminations')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments')
)
tags = columns.TagColumn(
url_name='circuits:virtualcircuit_list'
)
class Meta(NetBoxTable.Meta):
model = VirtualCircuit
fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',
'termination_count', 'description',
)
class VirtualCircuitTerminationTable(NetBoxTable):
virtual_circuit = tables.Column(
verbose_name=_('Virtual circuit'),
linkify=True
)
provider = tables.Column(
accessor=tables.A('virtual_circuit__provider_network__provider'),
verbose_name=_('Provider'),
linkify=True
)
provider_network = tables.Column(
accessor=tables.A('virtual_circuit__provider_network'),
linkify=True,
verbose_name=_('Provider network')
)
provider_account = tables.Column(
linkify=True,
verbose_name=_('Account')
)
role = columns.ChoiceFieldColumn()
device = tables.Column(
accessor=tables.A('interface__device'),
linkify=True,
verbose_name=_('Device')
)
interface = tables.Column(
verbose_name=_('Interface'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = VirtualCircuitTermination
fields = (
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
'description', 'created', 'last_updated', 'actions',
)
default_columns = (
'pk', 'id', 'virtual_circuit', 'role', 'device', 'interface', 'description',
)

View File

@@ -2,7 +2,8 @@ from django.urls import reverse
from circuits.choices import *
from circuits.models import *
from dcim.models import Site
from dcim.choices import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
from ipam.models import ASN, RIR
from utilities.testing import APITestCase, APIViewTestCases
@@ -92,10 +93,11 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
brief_fields = ['cid', 'description', 'display', 'id', 'url']
brief_fields = ['cid', 'description', 'display', 'id', 'provider', 'url']
bulk_update_data = {
'status': 'planned',
}
user_permissions = ('circuits.view_provider', 'circuits.view_circuittype')
@classmethod
def setUpTestData(cls):
@@ -119,9 +121,15 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
CircuitType.objects.bulk_create(circuit_types)
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
Circuit(cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
Circuit(cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]),
Circuit(
cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]
),
Circuit(
cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]
),
Circuit(
cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuit_types[0]
),
)
Circuit.objects.bulk_create(circuits)
@@ -150,6 +158,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination
brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
user_permissions = ('circuits.view_circuit', )
@classmethod
def setUpTestData(cls):
@@ -179,10 +188,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, termination=provider_networks[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -190,13 +199,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
{
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[0].pk,
'termination_type': 'dcim.site',
'termination_id': sites[0].pk,
'port_speed': 200000,
},
{
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'provider_network': provider_networks[0].pk,
'termination_type': 'circuits.providernetwork',
'termination_id': provider_networks[0].pk,
'port_speed': 200000,
},
]
@@ -206,9 +217,42 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
}
class CircuitGroupTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroup
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name="Circuit Group 1", slug='circuit-group-1'),
CircuitGroup(name="Circuit Group 2", slug='circuit-group-2'),
CircuitGroup(name="Circuit Group 3", slug='circuit-group-3'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
cls.create_data = [
{
'name': 'Circuit Group 4',
'slug': 'circuit-group-4',
},
{
'name': 'Circuit Group 5',
'slug': 'circuit-group-5',
},
{
'name': 'Circuit Group 6',
'slug': 'circuit-group-6',
},
]
class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
model = ProviderAccount
brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
user_permissions = ('circuits.view_provider',)
@classmethod
def setUpTestData(cls):
@@ -249,9 +293,85 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
}
class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroupAssignment
brief_fields = ['display', 'group', 'id', 'member', 'member_id', 'member_type', 'priority', 'url']
bulk_update_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}
user_permissions = ('circuits.view_circuit', 'circuits.view_circuitgroup')
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
CircuitGroup(name='Circuit Group 5', slug='circuit-group-5'),
CircuitGroup(name='Circuit Group 6', slug='circuit-group-6'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 4', provider=provider, type=circuittype),
Circuit(cid='Circuit 5', provider=provider, type=circuittype),
Circuit(cid='Circuit 6', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
cls.create_data = [
{
'group': circuit_groups[3].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_PRIMARY,
},
{
'group': circuit_groups[4].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[4].pk,
'priority': CircuitPriorityChoices.PRIORITY_SECONDARY,
},
{
'group': circuit_groups[5].pk,
'member_type': 'circuits.circuit',
'member_id': circuits[5].pk,
'priority': CircuitPriorityChoices.PRIORITY_TERTIARY,
},
]
class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
model = ProviderNetwork
brief_fields = ['description', 'display', 'id', 'name', 'url']
user_permissions = ('circuits.view_provider', )
@classmethod
def setUpTestData(cls):
@@ -287,3 +407,290 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
'provider': providers[1].pk,
'description': 'New description',
}
class VirtualCircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = VirtualCircuitType
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'virtual_circuit_count']
create_data = (
{
'name': 'Virtual Circuit Type 4',
'slug': 'virtual-circuit-type-4',
},
{
'name': 'Virtual Circuit Type 5',
'slug': 'virtual-circuit-type-5',
},
{
'name': 'Virtual Circuit Type 6',
'slug': 'virtual-circuit-type-6',
},
)
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
model = VirtualCircuit
brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 1'
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 2'
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
type=virtual_circuit_type,
cid='Virtual Circuit 3'
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
cls.create_data = [
{
'cid': 'Virtual Circuit 4',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
{
'cid': 'Virtual Circuit 5',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
{
'cid': 'Virtual Circuit 6',
'provider_network': provider_network.pk,
'provider_account': provider_account.pk,
'type': virtual_circuit_type.pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
},
]
class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = VirtualCircuitTermination
brief_fields = ['description', 'display', 'id', 'interface', 'role', 'url', 'virtual_circuit']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
site = Site.objects.create(name='Site 1', slug='site-1')
devices = (
Device(site=site, name='hub', device_type=device_type, role=device_role),
Device(site=site, name='spoke1', device_type=device_type, role=device_role),
Device(site=site, name='spoke2', device_type=device_type, role=device_role),
Device(site=site, name='spoke3', device_type=device_type, role=device_role),
)
Device.objects.bulk_create(devices)
physical_interfaces = (
Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(physical_interfaces)
virtual_interfaces = (
# Point-to-point VCs
Interface(
device=devices[0],
name='eth0.1',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[0],
name='eth0.2',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[0],
name='eth0.3',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[1],
name='eth0.1',
parent=physical_interfaces[1],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[2],
name='eth0.1',
parent=physical_interfaces[2],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[3],
name='eth0.1',
parent=physical_interfaces[3],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
# Hub and spoke VCs
Interface(
device=devices[0],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[1],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[2],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[3],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
)
Interface.objects.bulk_create(virtual_interfaces)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 4',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
virtual_circuit_terminations = (
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[0]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[3]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[1]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[4]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[2]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[5]
),
)
VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
cls.create_data = [
{
'virtual_circuit': virtual_circuits[3].pk,
'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
'interface': virtual_interfaces[6].pk
},
{
'virtual_circuit': virtual_circuits[3].pk,
'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
'interface': virtual_interfaces[7].pk
},
{
'virtual_circuit': virtual_circuits[3].pk,
'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
'interface': virtual_interfaces[8].pk
},
{
'virtual_circuit': virtual_circuits[3].pk,
'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
'interface': virtual_interfaces[9].pk
},
]

View File

@@ -3,8 +3,10 @@ from django.test import TestCase
from circuits.choices import *
from circuits.filtersets import *
from circuits.models import *
from dcim.models import Cable, Region, Site, SiteGroup
from dcim.choices import InterfaceTypeChoices
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
from ipam.models import ASN, RIR
from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests
@@ -69,10 +71,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Circuit.objects.bulk_create(circuits)
CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
))
circuit_terminations = (
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], termination=sites[0], term_side='A'),
)
for ct in circuit_terminations:
ct.save()
def test_q(self):
params = {'q': 'foobar1'}
@@ -222,24 +226,93 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], provider_account=provider_accounts[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], provider_account=provider_accounts[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[1], provider_account=provider_accounts[2], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(
provider=providers[0],
provider_account=provider_accounts[0],
tenant=tenants[0],
type=circuit_types[0],
cid='Test Circuit 1',
install_date='2020-01-01',
termination_date='2021-01-01',
commit_rate=1000,
status=CircuitStatusChoices.STATUS_ACTIVE,
description='foobar1',
distance=10,
distance_unit=DistanceUnitChoices.UNIT_FOOT,
),
Circuit(
provider=providers[0],
provider_account=provider_accounts[0],
tenant=tenants[0],
type=circuit_types[0],
cid='Test Circuit 2',
install_date='2020-01-02',
termination_date='2021-01-02',
commit_rate=2000,
status=CircuitStatusChoices.STATUS_ACTIVE,
description='foobar2',
distance=20,
distance_unit=DistanceUnitChoices.UNIT_METER,
),
Circuit(
provider=providers[0],
provider_account=provider_accounts[1],
tenant=tenants[1],
type=circuit_types[0],
cid='Test Circuit 3',
install_date='2020-01-03',
termination_date='2021-01-03',
commit_rate=3000,
status=CircuitStatusChoices.STATUS_PLANNED,
distance=30,
distance_unit=DistanceUnitChoices.UNIT_METER,
),
Circuit(
provider=providers[1],
provider_account=provider_accounts[1],
tenant=tenants[1],
type=circuit_types[1],
cid='Test Circuit 4',
install_date='2020-01-04',
termination_date='2021-01-04',
commit_rate=4000,
status=CircuitStatusChoices.STATUS_PLANNED,
),
Circuit(
provider=providers[1],
provider_account=provider_accounts[2],
tenant=tenants[2],
type=circuit_types[1],
cid='Test Circuit 5',
install_date='2020-01-05',
termination_date='2021-01-05',
commit_rate=5000,
status=CircuitStatusChoices.STATUS_OFFLINE,
),
Circuit(
provider=providers[1],
provider_account=provider_accounts[2],
tenant=tenants[2],
type=circuit_types[1],
cid='Test Circuit 6',
install_date='2020-01-06',
termination_date='2021-01-06',
commit_rate=6000,
status=CircuitStatusChoices.STATUS_OFFLINE,
),
)
Circuit.objects.bulk_create(circuits)
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
for ct in circuit_terminations:
ct.save()
def test_q(self):
params = {'q': 'foobar1'}
@@ -289,6 +362,14 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_distance(self):
params = {'distance': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_distance_unit(self):
params = {'distance_unit': DistanceUnitChoices.UNIT_FOOT}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -351,40 +432,89 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
circuit_terminations = (
CircuitTermination(
circuit=circuits[0],
termination=sites[0],
term_side='A',
port_speed=1000,
upstream_speed=1000,
xconnect_id='ABC',
description='foobar1',
),
CircuitTermination(
circuit=circuits[0],
termination=sites[1],
term_side='Z',
port_speed=1000,
upstream_speed=1000,
xconnect_id='DEF',
description='foobar2',
),
CircuitTermination(
circuit=circuits[1],
termination=sites[1],
term_side='A',
port_speed=2000,
upstream_speed=2000,
xconnect_id='GHI',
),
CircuitTermination(
circuit=circuits[1],
termination=sites[2],
term_side='Z',
port_speed=2000,
upstream_speed=2000,
xconnect_id='JKL',
),
CircuitTermination(
circuit=circuits[2],
termination=sites[2],
term_side='A',
port_speed=3000,
upstream_speed=3000,
xconnect_id='MNO',
),
CircuitTermination(
circuit=circuits[2],
termination=sites[0],
term_side='Z',
port_speed=3000,
upstream_speed=3000,
xconnect_id='PQR',
),
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
CircuitTermination(
circuit=circuits[6], termination=provider_networks[0], term_side='A', mark_connected=True
),
)
for ct in circuit_terminations:
ct.save()
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
@@ -413,10 +543,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.all()[:2]
circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -442,6 +579,185 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitGroup.objects.all()
filterset = CircuitGroupFilterSet
@classmethod
def setUpTestData(cls):
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
for tenantgroup in tenant_groups:
tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
CircuitGroup.objects.bulk_create((
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1', description='foobar1', tenant=tenants[0]),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2', description='foobar2', tenant=tenants[1]),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3', tenant=tenants[1]),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Circuit Group 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_slug(self):
params = {'slug': ['circuit-group-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
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]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitGroupAssignment.objects.all()
filterset = CircuitGroupAssignmentFilterSet
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
providers = Provider.objects.bulk_create((
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
))
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuit_type),
Circuit(cid='Circuit 2', provider=providers[1], type=circuit_type),
Circuit(cid='Circuit 3', provider=providers[2], type=circuit_type),
)
Circuit.objects.bulk_create(circuits)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[1],
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[2],
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
CircuitGroupAssignment(
group=circuit_groups[0],
member=virtual_circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=virtual_circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=virtual_circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
def test_group(self):
groups = CircuitGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_circuit(self):
circuits = Circuit.objects.all()[:2]
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'circuit': [circuits[0].cid, circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_circuit(self):
virtual_circuits = VirtualCircuit.objects.all()[:2]
params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_circuit': [virtual_circuits[0].cid, virtual_circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ProviderNetwork.objects.all()
filterset = ProviderNetworkFilterSet
@@ -526,3 +842,347 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualCircuitType.objects.all()
filterset = VirtualCircuitTypeFilterSet
@classmethod
def setUpTestData(cls):
VirtualCircuitType.objects.bulk_create((
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1', description='foobar1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2', description='foobar2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Virtual Circuit Type 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_slug(self):
params = {'slug': ['virtual-circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualCircuit.objects.all()
filterset = VirtualCircuitFilterSet
@classmethod
def setUpTestData(cls):
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
for tenantgroup in tenant_groups:
tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_accounts = (
ProviderAccount(name='Provider Account 1', provider=providers[0], account='A'),
ProviderAccount(name='Provider Account 2', provider=providers[1], account='B'),
ProviderAccount(name='Provider Account 3', provider=providers[2], account='C'),
)
ProviderAccount.objects.bulk_create(provider_accounts)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='virtual-circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
virutal_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
tenant=tenants[0],
cid='Virtual Circuit 1',
type=virtual_circuit_types[0],
status=CircuitStatusChoices.STATUS_PLANNED,
description='virtualcircuit1',
),
VirtualCircuit(
provider_network=provider_networks[1],
provider_account=provider_accounts[1],
tenant=tenants[1],
cid='Virtual Circuit 2',
type=virtual_circuit_types[1],
status=CircuitStatusChoices.STATUS_ACTIVE,
description='virtualcircuit2',
),
VirtualCircuit(
provider_network=provider_networks[2],
provider_account=provider_accounts[2],
tenant=tenants[2],
cid='Virtual Circuit 3',
type=virtual_circuit_types[2],
status=CircuitStatusChoices.STATUS_DEPROVISIONING,
description='virtualcircuit3',
),
)
VirtualCircuit.objects.bulk_create(virutal_circuits)
def test_q(self):
params = {'q': 'virtualcircuit1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_cid(self):
params = {'cid': ['Virtual Circuit 1', 'Virtual Circuit 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider_account(self):
provider_accounts = ProviderAccount.objects.all()[:2]
params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider_network(self):
provider_networks = ProviderNetwork.objects.all()[:2]
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
virtual_circuit_types = VirtualCircuitType.objects.all()[:2]
params = {'type_id': [virtual_circuit_types[0].pk, virtual_circuit_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'type': [virtual_circuit_types[0].slug, virtual_circuit_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['virtualcircuit1', 'virtualcircuit2']}
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]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualCircuitTermination.objects.all()
filterset = VirtualCircuitTerminationFilterSet
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
site = Site.objects.create(name='Site 1', slug='site-1')
devices = (
Device(site=site, name='Device 1', device_type=device_type, role=device_role),
Device(site=site, name='Device 2', device_type=device_type, role=device_role),
Device(site=site, name='Device 3', device_type=device_type, role=device_role),
)
Device.objects.bulk_create(devices)
virtual_interfaces = (
# Device 1
Interface(
device=devices[0],
name='eth0.1',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[0],
name='eth0.2',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
# Device 2
Interface(
device=devices[1],
name='eth0.1',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[1],
name='eth0.2',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
# Device 3
Interface(
device=devices[2],
name='eth0.1',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[2],
name='eth0.2',
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
)
Interface.objects.bulk_create(virtual_interfaces)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(provider=providers[0], name='Provider Network 1'),
ProviderNetwork(provider=providers[1], name='Provider Network 2'),
ProviderNetwork(provider=providers[2], name='Provider Network 3'),
)
ProviderNetwork.objects.bulk_create(provider_networks)
provider_accounts = (
ProviderAccount(provider=providers[0], account='Provider Account 1'),
ProviderAccount(provider=providers[1], account='Provider Account 2'),
ProviderAccount(provider=providers[2], account='Provider Account 3'),
)
ProviderAccount.objects.bulk_create(provider_accounts)
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[1],
provider_account=provider_accounts[1],
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_networks[2],
provider_account=provider_accounts[2],
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
virtual_circuit_terminations = (
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_HUB,
interface=virtual_interfaces[0],
description='termination1'
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
interface=virtual_interfaces[3],
description='termination2'
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[1],
description='termination3'
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[4],
description='termination4'
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[2],
description='termination5'
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[5],
description='termination6'
),
)
VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
def test_q(self):
params = {'q': 'termination1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['termination1', 'termination2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_circuit_id(self):
virtual_circuits = VirtualCircuit.objects.filter()[:2]
params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider_network(self):
provider_networks = ProviderNetwork.objects.all()[:2]
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider_account(self):
provider_accounts = ProviderAccount.objects.all()[:2]
params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'provider_account': [provider_accounts[0].account, provider_accounts[1].account]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_interface(self):
interfaces = Interface.objects.all()[:2]
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -1,12 +1,17 @@
import datetime
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from circuits.choices import *
from circuits.models import *
from dcim.models import Cable, Interface, Site
from core.models import ObjectType
from dcim.choices import InterfaceTypeChoices
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
from ipam.models import ASN, RIR
from netbox.choices import ImportFormatChoices
from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -115,6 +120,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
Site.objects.create(name='Site 1', slug='site-1')
providers = (
Provider(name='Provider 1', slug='provider-1'),
@@ -135,9 +141,15 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
CircuitType.objects.bulk_create(circuittypes)
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
Circuit(cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
Circuit(cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]),
Circuit(
cid='Circuit 1', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]
),
Circuit(
cid='Circuit 2', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]
),
Circuit(
cid='Circuit 3', provider=providers[0], provider_account=provider_accounts[0], type=circuittypes[0]
),
)
Circuit.objects.bulk_create(circuits)
@@ -167,7 +179,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
cls.csv_update_data = (
f"id,cid,description,status",
"id,cid,description,status",
f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
@@ -184,6 +196,55 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self):
site = Site.objects.first()
json_data = f"""
[
{{
"cid": "Circuit 7",
"provider": "Provider 1",
"type": "Circuit Type 1",
"status": "active",
"description": "Testing Import",
"terminations": [
{{
"term_side": "A",
"termination_type": "dcim.site",
"termination_id": "{site.pk}"
}},
{{
"term_side": "Z",
"termination_type": "dcim.site",
"termination_id": "{site.pk}"
}}
]
}}
]
"""
initial_count = self._get_queryset().count()
data = {
'data': json_data,
'format': ImportFormatChoices.JSON,
}
# Assign model-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
# Test POST with permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + 1)
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount
@@ -287,10 +348,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class CircuitTerminationTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination
@classmethod
@@ -313,17 +371,38 @@ class CircuitTerminationTestCase(
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]),
CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]),
CircuitTermination(circuit=circuits[0], term_side='A', termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]),
CircuitTermination(circuit=circuits[1], term_side='Z', termination=sites[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
for ct in circuit_terminations:
ct.save()
cls.form_data = {
'circuit': circuits[2].pk,
'term_side': 'A',
'site': sites[2].pk,
'termination_type': ContentType.objects.get_for_model(Site).pk,
'termination': sites[2].pk,
'description': 'New description',
}
site = sites[0].pk
cls.csv_data = (
"circuit,term_side,termination_type,termination_id,description",
f"Circuit 3,A,dcim.site,{site},Foo",
f"Circuit 3,Z,dcim.site,{site},Bar",
)
cls.csv_update_data = (
"id,port_speed,description",
f"{circuit_terminations[0].pk},100,New description7",
f"{circuit_terminations[1].pk},200,New description8",
f"{circuit_terminations[2].pk},300,New description9",
)
cls.bulk_edit_data = {
'port_speed': 400,
'description': 'New description',
}
@@ -340,3 +419,519 @@ class CircuitTerminationTestCase(
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)
class CircuitGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = CircuitGroup
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Circuit Group X',
'slug': 'circuit-group-x',
'description': 'A new Circuit Group',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug",
"Circuit Group 4,circuit-group-4",
"Circuit Group 5,circuit-group-5",
"Circuit Group 6,circuit-group-6",
)
cls.csv_update_data = (
"id,name,description",
f"{circuit_groups[0].pk},Circuit Group 7,New description7",
f"{circuit_groups[1].pk},Circuit Group 8,New description8",
f"{circuit_groups[2].pk},Circuit Group 9,New description9",
)
cls.bulk_edit_data = {
'description': 'Foo',
}
class CircuitGroupAssignmentTestCase(
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = CircuitGroupAssignment
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 4', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'group': circuit_groups[3].pk,
'member_type': ContentType.objects.get_for_model(Circuit).pk,
'member': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"member_type,member_id,group,priority",
f"circuits.circuit,{circuits[0].pk},{circuit_groups[3].pk},primary",
f"circuits.circuit,{circuits[1].pk},{circuit_groups[3].pk},secondary",
f"circuits.circuit,{circuits[2].pk},{circuit_groups[3].pk},tertiary",
)
cls.csv_update_data = (
"id,priority",
f"{assignments[0].pk},inactive",
f"{assignments[1].pk},inactive",
f"{assignments[2].pk},inactive",
)
cls.bulk_edit_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}
class VirtualCircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = VirtualCircuitType
@classmethod
def setUpTestData(cls):
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='circuit-type-2'),
VirtualCircuitType(name='Virtual Circuit Type 3', slug='circuit-type-3'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Virtual Circuit Type X',
'slug': 'virtual-circuit-type-x',
'description': 'A new virtual circuit type',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug",
"Virtual Circuit Type 4,circuit-type-4",
"Virtual Circuit Type 5,circuit-type-5",
"Virtual Circuit Type 6,circuit-type-6",
)
cls.csv_update_data = (
"id,name,description",
f"{virtual_circuit_types[0].pk},Virtual Circuit Type 7,New description7",
f"{virtual_circuit_types[1].pk},Virtual Circuit Type 8,New description8",
f"{virtual_circuit_types[2].pk},Virtual Circuit Type 9,New description9",
)
cls.bulk_edit_data = {
'description': 'Foo',
}
class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualCircuit
def setUp(self):
super().setUp()
self.add_permissions(
'circuits.add_virtualcircuittermination',
)
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_networks = (
ProviderNetwork(provider=provider, name='Provider Network 1'),
ProviderNetwork(provider=provider, name='Provider Network 2'),
)
ProviderNetwork.objects.bulk_create(provider_networks)
provider_accounts = (
ProviderAccount(provider=provider, account='Provider Account 1'),
ProviderAccount(provider=provider, account='Provider Account 2'),
)
ProviderAccount.objects.bulk_create(provider_accounts)
virtual_circuit_types = (
VirtualCircuitType(name='Virtual Circuit Type 1', slug='virtual-circuit-type-1'),
VirtualCircuitType(name='Virtual Circuit Type 2', slug='virtual-circuit-type-2'),
)
VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 1',
type=virtual_circuit_types[0]
),
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 2',
type=virtual_circuit_types[0]
),
VirtualCircuit(
provider_network=provider_networks[0],
provider_account=provider_accounts[0],
cid='Virtual Circuit 3',
type=virtual_circuit_types[0]
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
device = create_test_device('Device 1')
interfaces = (
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
)
Interface.objects.bulk_create(interfaces)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cid': 'Virtual Circuit X',
'provider_network': provider_networks[1].pk,
'provider_account': provider_accounts[1].pk,
'type': virtual_circuit_types[1].pk,
'status': CircuitStatusChoices.STATUS_PLANNED,
'description': 'A new virtual circuit',
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"cid,provider_network,provider_account,type,status",
(
f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
(
f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
(
f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{virtual_circuit_types[0].name},"
f"{CircuitStatusChoices.STATUS_PLANNED}"
),
)
cls.csv_update_data = (
"id,cid,description,type,status",
(
f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
(
f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
(
f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{virtual_circuit_types[1].name},"
f"{CircuitStatusChoices.STATUS_DECOMMISSIONED}"
),
)
cls.bulk_edit_data = {
'provider_network': provider_networks[1].pk,
'provider_account': provider_accounts[1].pk,
'type': virtual_circuit_types[1].pk,
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'description': 'New description',
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self):
interfaces = Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL)
json_data = f"""
[
{{
"cid": "Virtual Circuit 7",
"provider_network": "Provider Network 1",
"type": "Virtual Circuit Type 1",
"status": "active",
"terminations": [
{{
"role": "hub",
"interface": {interfaces[0].pk}
}},
{{
"role": "spoke",
"interface": {interfaces[1].pk}
}},
{{
"role": "spoke",
"interface": {interfaces[2].pk}
}}
]
}}
]
"""
initial_count = self._get_queryset().count()
data = {
'data': json_data,
'format': ImportFormatChoices.JSON,
}
# Assign model-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('bulk_import')), 200)
# Test POST with permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + 1)
class VirtualCircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualCircuitTermination
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
site = Site.objects.create(name='Site 1', slug='site-1')
devices = (
Device(site=site, name='hub', device_type=device_type, role=device_role),
Device(site=site, name='spoke1', device_type=device_type, role=device_role),
Device(site=site, name='spoke2', device_type=device_type, role=device_role),
Device(site=site, name='spoke3', device_type=device_type, role=device_role),
)
Device.objects.bulk_create(devices)
physical_interfaces = (
Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(physical_interfaces)
virtual_interfaces = (
# Point-to-point VCs
Interface(
device=devices[0],
name='eth0.1',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[0],
name='eth0.2',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[0],
name='eth0.3',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[1],
name='eth0.1',
parent=physical_interfaces[1],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[2],
name='eth0.1',
parent=physical_interfaces[2],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[3],
name='eth0.1',
parent=physical_interfaces[3],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
# Hub and spoke VCs
Interface(
device=devices[0],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[1],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[2],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
Interface(
device=devices[3],
name='eth0.9',
parent=physical_interfaces[0],
type=InterfaceTypeChoices.TYPE_VIRTUAL
),
)
Interface.objects.bulk_create(virtual_interfaces)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
virtual_circuit_type = VirtualCircuitType.objects.create(
name='Virtual Circuit Type 1',
slug='virtual-circuit-type-1'
)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 1',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 2',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 3',
type=virtual_circuit_type
),
VirtualCircuit(
provider_network=provider_network,
provider_account=provider_account,
cid='Virtual Circuit 4',
type=virtual_circuit_type
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
virtual_circuit_terminations = (
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[0]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[0],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[3]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[1]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[1],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[4]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[2]
),
VirtualCircuitTermination(
virtual_circuit=virtual_circuits[2],
role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
interface=virtual_interfaces[5]
),
)
VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
cls.form_data = {
'virtual_circuit': virtual_circuits[3].pk,
'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
'interface': virtual_interfaces[6].pk
}
cls.csv_data = (
"virtual_circuit,role,interface,description",
f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_HUB},{virtual_interfaces[6].pk},Hub",
f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[7].pk},Spoke 1",
f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[8].pk},Spoke 2",
f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[9].pk},Spoke 3",
)
cls.csv_update_data = (
"id,role,description",
f"{virtual_circuit_terminations[0].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
f"{virtual_circuit_terminations[1].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
f"{virtual_circuit_terminations[2].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
)
cls.bulk_edit_data = {
'description': 'New description',
}

View File

@@ -5,50 +5,71 @@ from . import views
app_name = 'circuits'
urlpatterns = [
# Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers/', include(get_model_urls('circuits', 'provider', detail=False))),
path('providers/<int:pk>/', include(get_model_urls('circuits', 'provider'))),
# Provider accounts
path('provider-accounts/', views.ProviderAccountListView.as_view(), name='provideraccount_list'),
path('provider-accounts/add/', views.ProviderAccountEditView.as_view(), name='provideraccount_add'),
path('provider-accounts/import/', views.ProviderAccountBulkImportView.as_view(), name='provideraccount_import'),
path('provider-accounts/edit/', views.ProviderAccountBulkEditView.as_view(), name='provideraccount_bulk_edit'),
path('provider-accounts/delete/', views.ProviderAccountBulkDeleteView.as_view(), name='provideraccount_bulk_delete'),
path('provider-accounts/', include(get_model_urls('circuits', 'provideraccount', detail=False))),
path('provider-accounts/<int:pk>/', include(get_model_urls('circuits', 'provideraccount'))),
# Provider networks
path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
path('provider-networks/add/', views.ProviderNetworkEditView.as_view(), name='providernetwork_add'),
path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'),
path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'),
path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'),
path('provider-networks/', include(get_model_urls('circuits', 'providernetwork', detail=False))),
path('provider-networks/<int:pk>/', include(get_model_urls('circuits', 'providernetwork'))),
# 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/', include(get_model_urls('circuits', 'circuittype', detail=False))),
path('circuit-types/<int:pk>/', include(get_model_urls('circuits', 'circuittype'))),
# Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
path('circuits/', include(get_model_urls('circuits', 'circuit', detail=False))),
path(
'circuits/<int:pk>/terminations/swap/',
views.CircuitSwapTerminations.as_view(),
name='circuit_terminations_swap'
),
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/', include(get_model_urls('circuits', 'circuittermination', detail=False))),
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
path('circuit-groups/', include(get_model_urls('circuits', 'circuitgroup', detail=False))),
path('circuit-groups/<int:pk>/', include(get_model_urls('circuits', 'circuitgroup'))),
path('circuit-group-assignments/', include(get_model_urls('circuits', 'circuitgroupassignment', detail=False))),
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
# Virtual circuits
path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
path('virtual-circuit-types/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittype'))),
# Virtual circuit terminations
path(
'virtual-circuit-terminations/',
views.VirtualCircuitTerminationListView.as_view(),
name='virtualcircuittermination_list',
),
path(
'virtual-circuit-terminations/add/',
views.VirtualCircuitTerminationEditView.as_view(),
name='virtualcircuittermination_add',
),
path(
'virtual-circuit-terminations/import/',
views.VirtualCircuitTerminationBulkImportView.as_view(),
name='virtualcircuittermination_bulk_import',
),
path(
'virtual-circuit-terminations/edit/',
views.VirtualCircuitTerminationBulkEditView.as_view(),
name='virtualcircuittermination_bulk_edit',
),
path(
'virtual-circuit-terminations/delete/',
views.VirtualCircuitTerminationBulkDeleteView.as_view(),
name='virtualcircuittermination_bulk_delete',
),
path('virtual-circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittermination'))),
]

View File

@@ -1,13 +1,14 @@
from django.contrib import messages
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.query import count_related
from utilities.views import register_model_view
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -16,6 +17,7 @@ from .models import *
# Providers
#
@register_model_view(Provider, 'list', path='', detail=False)
class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
@@ -26,20 +28,16 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider)
class ProviderView(generic.ObjectView):
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
(Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@register_model_view(Provider, 'add', detail=False)
@register_model_view(Provider, 'edit')
class ProviderEditView(generic.ObjectEditView):
queryset = Provider.objects.all()
@@ -51,11 +49,13 @@ class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_import', detail=False)
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderImportForm
@register_model_view(Provider, 'bulk_edit', path='edit', detail=False)
class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
@@ -65,6 +65,7 @@ class ProviderBulkEditView(generic.BulkEditView):
form = forms.ProviderBulkEditForm
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
@@ -82,6 +83,7 @@ class ProviderContactsView(ObjectContactsView):
# ProviderAccounts
#
@register_model_view(ProviderAccount, 'list', path='', detail=False)
class ProviderAccountListView(generic.ObjectListView):
queryset = ProviderAccount.objects.annotate(
count_circuits=count_related(Circuit, 'provider_account')
@@ -92,19 +94,16 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(generic.ObjectView):
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@register_model_view(ProviderAccount, 'add', detail=False)
@register_model_view(ProviderAccount, 'edit')
class ProviderAccountEditView(generic.ObjectEditView):
queryset = ProviderAccount.objects.all()
@@ -116,12 +115,14 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_import', detail=False)
class ProviderAccountBulkImportView(generic.BulkImportView):
queryset = ProviderAccount.objects.all()
model_form = forms.ProviderAccountImportForm
table = tables.ProviderAccountTable
@register_model_view(ProviderAccount, 'bulk_edit', path='edit', detail=False)
class ProviderAccountBulkEditView(generic.BulkEditView):
queryset = ProviderAccount.objects.annotate(
count_circuits=count_related(Circuit, 'provider_account')
@@ -131,6 +132,7 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
form = forms.ProviderAccountBulkEditForm
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderAccount.objects.annotate(
count_circuits=count_related(Circuit, 'provider_account')
@@ -148,6 +150,7 @@ class ProviderAccountContactsView(ObjectContactsView):
# Provider networks
#
@register_model_view(ProviderNetwork, 'list', path='', detail=False)
class ProviderNetworkListView(generic.ObjectListView):
queryset = ProviderNetwork.objects.all()
filterset = filtersets.ProviderNetworkFilterSet
@@ -156,22 +159,25 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView):
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
'provider_network_id',
),
),
),
}
@register_model_view(ProviderNetwork, 'add', detail=False)
@register_model_view(ProviderNetwork, 'edit')
class ProviderNetworkEditView(generic.ObjectEditView):
queryset = ProviderNetwork.objects.all()
@@ -183,11 +189,13 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_import', detail=False)
class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all()
model_form = forms.ProviderNetworkImportForm
@register_model_view(ProviderNetwork, 'bulk_edit', path='edit', detail=False)
class ProviderNetworkBulkEditView(generic.BulkEditView):
queryset = ProviderNetwork.objects.all()
filterset = filtersets.ProviderNetworkFilterSet
@@ -195,6 +203,7 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
form = forms.ProviderNetworkBulkEditForm
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderNetwork.objects.all()
filterset = filtersets.ProviderNetworkFilterSet
@@ -205,6 +214,7 @@ class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
# Circuit Types
#
@register_model_view(CircuitType, 'list', path='', detail=False)
class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
@@ -215,19 +225,16 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView):
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@register_model_view(CircuitType, 'add', detail=False)
@register_model_view(CircuitType, 'edit')
class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all()
@@ -239,11 +246,13 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_import', detail=False)
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeImportForm
@register_model_view(CircuitType, 'bulk_edit', path='edit', detail=False)
class CircuitTypeBulkEditView(generic.BulkEditView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
@@ -253,6 +262,7 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
form = forms.CircuitTypeBulkEditForm
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
@@ -265,10 +275,10 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
# Circuits
#
@register_model_view(Circuit, 'list', path='', detail=False)
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'tenant__group', 'termination_a__termination', 'termination_z__termination',
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
@@ -280,6 +290,7 @@ class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all()
@register_model_view(Circuit, 'add', detail=False)
@register_model_view(Circuit, 'edit')
class CircuitEditView(generic.ObjectEditView):
queryset = Circuit.objects.all()
@@ -291,6 +302,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
@register_model_view(Circuit, 'bulk_import', detail=False)
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
@@ -298,7 +310,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination',
]
related_object_forms = {
'terminations': forms.CircuitTerminationImportForm,
'terminations': forms.CircuitTerminationImportRelatedForm,
}
def prep_related_object_data(self, parent, data):
@@ -306,20 +318,20 @@ class CircuitBulkImportView(generic.BulkImportView):
return data
@register_model_view(Circuit, 'bulk_edit', path='edit', detail=False)
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'tenant__group', 'termination_a__termination', 'termination_z__termination',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
form = forms.CircuitBulkEditForm
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
'tenant__group', 'termination_a__termination', 'termination_z__termination',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
@@ -337,7 +349,9 @@ class CircuitSwapTerminations(generic.ObjectEditView):
# Circuit must have at least one termination to swap
if not circuit.termination_a and not circuit.termination_z:
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
messages.error(request, _(
"No terminations have been defined for circuit {circuit}."
).format(circuit=circuit))
return redirect('circuits:circuit', pk=circuit.pk)
return render(request, 'circuits/circuit_terminations_swap.html', {
@@ -385,7 +399,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
circuit.termination_z = None
circuit.save()
messages.success(request, f"Swapped terminations for circuit {circuit}.")
messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit))
return redirect('circuits:circuit', pk=circuit.pk)
return render(request, 'circuits/circuit_terminations_swap.html', {
@@ -408,6 +422,20 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations
#
@register_model_view(CircuitTermination, 'list', path='', detail=False)
class CircuitTerminationListView(generic.ObjectListView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'add', detail=False)
@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
@@ -419,5 +447,300 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'bulk_import', detail=False)
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
@register_model_view(CircuitTermination, 'bulk_edit', path='edit', detail=False)
class CircuitTerminationBulkEditView(generic.BulkEditView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
form = forms.CircuitTerminationBulkEditForm
@register_model_view(CircuitTermination, 'bulk_delete', path='delete', detail=False)
class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
#
# Circuit Groups
#
@register_model_view(CircuitGroup, 'list', path='', detail=False)
class CircuitGroupListView(generic.ObjectListView):
queryset = CircuitGroup.objects.annotate(
circuit_group_assignment_count=count_related(CircuitGroupAssignment, 'group')
)
filterset = filtersets.CircuitGroupFilterSet
filterset_form = forms.CircuitGroupFilterForm
table = tables.CircuitGroupTable
@register_model_view(CircuitGroup)
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitGroup.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(CircuitGroup, 'add', detail=False)
@register_model_view(CircuitGroup, 'edit')
class CircuitGroupEditView(generic.ObjectEditView):
queryset = CircuitGroup.objects.all()
form = forms.CircuitGroupForm
@register_model_view(CircuitGroup, 'delete')
class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_import', detail=False)
class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm
@register_model_view(CircuitGroup, 'bulk_edit', path='edit', detail=False)
class CircuitGroupBulkEditView(generic.BulkEditView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
table = tables.CircuitGroupTable
form = forms.CircuitGroupBulkEditForm
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
table = tables.CircuitGroupTable
#
# Circuit Groups
#
@register_model_view(CircuitGroupAssignment, 'list', path='', detail=False)
class CircuitGroupAssignmentListView(generic.ObjectListView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable
@register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView):
queryset = CircuitGroupAssignment.objects.all()
@register_model_view(CircuitGroupAssignment, 'add', detail=False)
@register_model_view(CircuitGroupAssignment, 'edit')
class CircuitGroupAssignmentEditView(generic.ObjectEditView):
queryset = CircuitGroupAssignment.objects.all()
form = forms.CircuitGroupAssignmentForm
@register_model_view(CircuitGroupAssignment, 'delete')
class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all()
@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False)
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm
@register_model_view(CircuitGroupAssignment, 'bulk_edit', path='edit', detail=False)
class CircuitGroupAssignmentBulkEditView(generic.BulkEditView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
table = tables.CircuitGroupAssignmentTable
form = forms.CircuitGroupAssignmentBulkEditForm
@register_model_view(CircuitGroupAssignment, 'bulk_delete', path='delete', detail=False)
class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
table = tables.CircuitGroupAssignmentTable
#
# Virtual circuit Types
#
@register_model_view(VirtualCircuitType, 'list', path='', detail=False)
class VirtualCircuitTypeListView(generic.ObjectListView):
queryset = VirtualCircuitType.objects.annotate(
virtual_circuit_count=count_related(VirtualCircuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
filterset_form = forms.VirtualCircuitTypeFilterForm
table = tables.VirtualCircuitTypeTable
@register_model_view(VirtualCircuitType)
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualCircuitType.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(VirtualCircuitType, 'add', detail=False)
@register_model_view(VirtualCircuitType, 'edit')
class VirtualCircuitTypeEditView(generic.ObjectEditView):
queryset = VirtualCircuitType.objects.all()
form = forms.VirtualCircuitTypeForm
@register_model_view(VirtualCircuitType, 'delete')
class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm
@register_model_view(VirtualCircuitType, 'bulk_edit', path='edit', detail=False)
class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
queryset = VirtualCircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
table = tables.VirtualCircuitTypeTable
form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.VirtualCircuitTypeFilterSet
table = tables.VirtualCircuitTypeTable
#
# Virtual circuits
#
class VirtualCircuitListView(generic.ObjectListView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
)
filterset = filtersets.VirtualCircuitFilterSet
filterset_form = forms.VirtualCircuitFilterForm
table = tables.VirtualCircuitTable
@register_model_view(VirtualCircuit)
class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all()
@register_model_view(VirtualCircuit, 'edit')
class VirtualCircuitEditView(generic.ObjectEditView):
queryset = VirtualCircuit.objects.all()
form = forms.VirtualCircuitForm
@register_model_view(VirtualCircuit, 'delete')
class VirtualCircuitDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuit.objects.all()
class VirtualCircuitBulkImportView(generic.BulkImportView):
queryset = VirtualCircuit.objects.all()
model_form = forms.VirtualCircuitImportForm
additional_permissions = [
'circuits.add_virtualcircuittermination',
]
related_object_forms = {
'terminations': forms.VirtualCircuitTerminationImportRelatedForm,
}
def prep_related_object_data(self, parent, data):
data.update({'virtual_circuit': parent})
return data
class VirtualCircuitBulkEditView(generic.BulkEditView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
)
filterset = filtersets.VirtualCircuitFilterSet
table = tables.VirtualCircuitTable
form = forms.VirtualCircuitBulkEditForm
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
)
filterset = filtersets.VirtualCircuitFilterSet
table = tables.VirtualCircuitTable
#
# Virtual circuit terminations
#
class VirtualCircuitTerminationListView(generic.ObjectListView):
queryset = VirtualCircuitTermination.objects.all()
filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable
@register_model_view(VirtualCircuitTermination)
class VirtualCircuitTerminationView(generic.ObjectView):
queryset = VirtualCircuitTermination.objects.all()
@register_model_view(VirtualCircuitTermination, 'edit')
class VirtualCircuitTerminationEditView(generic.ObjectEditView):
queryset = VirtualCircuitTermination.objects.all()
form = forms.VirtualCircuitTerminationForm
@register_model_view(VirtualCircuitTermination, 'delete')
class VirtualCircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitTermination.objects.all()
class VirtualCircuitTerminationBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitTermination.objects.all()
model_form = forms.VirtualCircuitTerminationImportForm
class VirtualCircuitTerminationBulkEditView(generic.BulkEditView):
queryset = VirtualCircuitTermination.objects.all()
filterset = filtersets.VirtualCircuitTerminationFilterSet
table = tables.VirtualCircuitTerminationTable
form = forms.VirtualCircuitTerminationBulkEditForm
class VirtualCircuitTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitTermination.objects.all()
filterset = filtersets.VirtualCircuitTerminationFilterSet
table = tables.VirtualCircuitTerminationTable

View File

@@ -1,42 +0,0 @@
from rest_framework import serializers
from core.choices import JobStatusChoices
from core.models import *
from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
from users.api.serializers import UserSerializer
__all__ = (
'NestedDataFileSerializer',
'NestedDataSourceSerializer',
'NestedJobSerializer',
)
class NestedDataSourceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
class Meta:
model = DataSource
fields = ['id', 'url', 'display', 'name']
class NestedDataFileSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
class Meta:
model = DataFile
fields = ['id', 'url', 'display', 'path']
class NestedJobSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
status = ChoiceField(choices=JobStatusChoices)
user = UserSerializer(
nested=True,
read_only=True
)
class Meta:
model = Job
fields = ['url', 'created', 'completed', 'user', 'status']

View File

@@ -8,10 +8,8 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
# see netbox.api.routers.NetBoxRouter
@@ -37,7 +35,10 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
elif direction == "response":
value = build_cf
label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
label = {
**build_basic_type(OpenApiTypes.STR),
"enum": list(OrderedDict.fromkeys(self.target.choices.values()))
}
return build_object_type(
properties={
@@ -126,9 +127,18 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
# If this serializer is nested, prepend its name with "Brief"
if getattr(serializer, 'nested', False):
name = f'Brief{name}'
return name
def get_serializer_ref_name(self, serializer):
# from drf-yasg.utils
"""Get serializer's ref_name (or None for ModelSerializer if it is named 'NestedSerializer')
"""Get serializer's ref_name
:param serializer: Serializer instance
:return: Serializer's ``ref_name`` or ``None`` for inline serializer
:rtype: str or None
@@ -137,8 +147,6 @@ class NetBoxAutoSchema(AutoSchema):
serializer_name = type(serializer).__name__
if hasattr(serializer_meta, 'ref_name'):
ref_name = serializer_meta.ref_name
elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
ref_name = None
else:
ref_name = serializer_name
if ref_name.endswith('Serializer'):
@@ -150,6 +158,9 @@ class NetBoxAutoSchema(AutoSchema):
fields = {} if hasattr(serializer, 'child') else serializer.fields
remove_fields = []
# If you get a failure here for "AttributeError: 'cached_property' object has no attribute 'items'"
# it is probably because you are using a viewsets.ViewSet for the API View and are defining a
# serializer_class. You will also need to define a get_serializer() method like for GenericAPIView.
for child_name, child in fields.items():
# read_only fields don't need to be in writable (write only) serializers
if 'read_only' in dir(child) and child.read_only:
@@ -255,3 +266,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects."
class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.SerializedPKRelatedField'
def map_serializer_field(self, auto_schema, direction):
if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction)
return component.ref if component else None
else:
return build_basic_type(OpenApiTypes.INT)

View File

@@ -1,3 +1,4 @@
from .serializers_.change_logging import *
from .serializers_.data import *
from .serializers_.jobs import *
from .nested_serializers import *
from .serializers_.tasks import *

View File

@@ -1,8 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from extras.choices import *
from extras.models import ObjectChange
from core.choices import *
from core.models import ObjectChange
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer
@@ -15,7 +15,6 @@ __all__ = (
class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = UserSerializer(
nested=True,
read_only=True
@@ -30,12 +29,22 @@ class ObjectChangeSerializer(BaseModelSerializer):
changed_object = serializers.SerializerMethodField(
read_only=True
)
prechange_data = serializers.JSONField(
source='prechange_data_clean',
read_only=True,
allow_null=True
)
postchange_data = serializers.JSONField(
source='postchange_data_clean',
read_only=True,
allow_null=True
)
class Meta:
model = ObjectChange
fields = [
'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@@ -1,5 +1,3 @@
from rest_framework import serializers
from core.choices import *
from core.models import DataFile, DataSource
from netbox.api.fields import ChoiceField, RelatedObjectCountField
@@ -13,9 +11,6 @@ __all__ = (
class DataSourceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datasource-detail'
)
type = ChoiceField(
choices=get_data_backend_choices()
)
@@ -30,16 +25,14 @@ class DataSourceSerializer(NetBoxModelSerializer):
class Meta:
model = DataSource
fields = [
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced',
'file_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DataFileSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datafile-detail'
)
source = DataSourceSerializer(
nested=True,
read_only=True
@@ -48,6 +41,6 @@ class DataFileSerializer(NetBoxModelSerializer):
class Meta:
model = DataFile
fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]
brief_fields = ('id', 'url', 'display', 'path')

View File

@@ -1,5 +1,3 @@
from rest_framework import serializers
from core.choices import *
from core.models import Job
from netbox.api.fields import ChoiceField, ContentTypeField
@@ -12,7 +10,6 @@ __all__ = (
class JobSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
user = UserSerializer(
nested=True,
read_only=True
@@ -25,7 +22,7 @@ class JobSerializer(BaseModelSerializer):
class Meta:
model = Job
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'error', 'job_id',
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@@ -0,0 +1,87 @@
from rest_framework import serializers
from rest_framework.reverse import reverse
__all__ = (
'BackgroundTaskSerializer',
'BackgroundQueueSerializer',
'BackgroundWorkerSerializer',
)
class BackgroundTaskSerializer(serializers.Serializer):
id = serializers.CharField()
url = serializers.HyperlinkedIdentityField(
view_name='core-api:rqtask-detail',
lookup_field='id',
lookup_url_kwarg='pk'
)
description = serializers.CharField()
origin = serializers.CharField()
func_name = serializers.CharField()
args = serializers.ListField(child=serializers.CharField())
kwargs = serializers.DictField()
result = serializers.CharField()
timeout = serializers.IntegerField()
result_ttl = serializers.IntegerField()
created_at = serializers.DateTimeField()
enqueued_at = serializers.DateTimeField()
started_at = serializers.DateTimeField()
ended_at = serializers.DateTimeField()
worker_name = serializers.CharField()
position = serializers.SerializerMethodField()
status = serializers.SerializerMethodField()
meta = serializers.DictField()
last_heartbeat = serializers.CharField()
is_finished = serializers.BooleanField()
is_queued = serializers.BooleanField()
is_failed = serializers.BooleanField()
is_started = serializers.BooleanField()
is_deferred = serializers.BooleanField()
is_canceled = serializers.BooleanField()
is_scheduled = serializers.BooleanField()
is_stopped = serializers.BooleanField()
def get_position(self, obj) -> int:
return obj.get_position()
def get_status(self, obj) -> str:
return obj.get_status()
class BackgroundQueueSerializer(serializers.Serializer):
name = serializers.CharField()
url = serializers.SerializerMethodField()
jobs = serializers.IntegerField()
oldest_job_timestamp = serializers.CharField()
index = serializers.IntegerField()
scheduler_pid = serializers.CharField()
workers = serializers.IntegerField()
finished_jobs = serializers.IntegerField()
started_jobs = serializers.IntegerField()
deferred_jobs = serializers.IntegerField()
failed_jobs = serializers.IntegerField()
scheduled_jobs = serializers.IntegerField()
def get_url(self, obj):
return reverse('core-api:rqqueue-detail', args=[obj['name']], request=self.context.get("request"))
class BackgroundWorkerSerializer(serializers.Serializer):
name = serializers.CharField()
url = serializers.HyperlinkedIdentityField(
view_name='core-api:rqworker-detail',
lookup_field='name'
)
state = serializers.SerializerMethodField()
birth_date = serializers.DateTimeField()
queue_names = serializers.ListField(
child=serializers.CharField()
)
pid = serializers.CharField()
successful_job_count = serializers.IntegerField()
failed_job_count = serializers.IntegerField()
total_working_time = serializers.IntegerField()
def get_state(self, obj):
return obj.get_state()

View File

@@ -1,16 +1,17 @@
from netbox.api.routers import NetBoxRouter
from . import views
app_name = 'core-api'
router = NetBoxRouter()
router.APIRootView = views.CoreRootView
# Data sources
router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)
# Jobs
router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')
app_name = 'core-api'
urlpatterns = router.urls

View File

@@ -1,5 +1,8 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -7,8 +10,20 @@ from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet
from core import filtersets
from core.choices import DataSourceStatusChoices
from core.jobs import SyncDataSourceJob
from core.models import *
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
from django_rq.queues import get_redis_connection
from django_rq.utils import get_statistics
from django_rq.settings import QUEUES_LIST
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser
from rq.job import Job as RQ_Job
from rq.worker import Worker
from . import serializers
@@ -35,7 +50,11 @@ class DataSourceViewSet(NetBoxModelViewSet):
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
datasource.enqueue_sync_job(request)
# Enqueue the sync job & update the DataSource's status
SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
return Response(serializer.data)
@@ -54,3 +73,162 @@ class JobViewSet(ReadOnlyModelViewSet):
queryset = Job.objects.all()
serializer_class = serializers.JobSerializer
filterset_class = filtersets.JobFilterSet
class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet
class BaseRQViewSet(viewsets.ViewSet):
"""
Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().
"""
permission_classes = [IsAdminUser]
serializer_class = None
def get_data(self):
raise NotImplementedError()
@extend_schema(responses={200: OpenApiTypes.OBJECT})
def list(self, request):
data = self.get_data()
paginator = LimitOffsetListPagination()
data = paginator.paginate_list(data, request)
serializer = self.serializer_class(data, many=True, context={'request': request})
return paginator.get_paginated_response(serializer.data)
def get_serializer(self, *args, **kwargs):
"""
Return the serializer instance that should be used for validating and
deserializing input, and for serializing output.
"""
serializer_class = self.get_serializer_class()
kwargs['context'] = self.get_serializer_context()
return serializer_class(*args, **kwargs)
class BackgroundQueueViewSet(BaseRQViewSet):
"""
Retrieve a list of RQ Queues.
Note: Queue names are not URL safe so not returning a detail view.
"""
serializer_class = serializers.BackgroundQueueSerializer
lookup_field = 'name'
lookup_value_regex = r'[\w.@+-]+'
def get_view_name(self):
return "Background Queues"
def get_data(self):
return get_statistics(run_maintenance_tasks=True)["queues"]
@extend_schema(responses={200: OpenApiTypes.OBJECT})
def retrieve(self, request, name):
data = self.get_data()
if not data:
raise Http404
for queue in data:
if queue['name'] == name:
serializer = self.serializer_class(queue, context={'request': request})
return Response(serializer.data)
raise Http404
class BackgroundWorkerViewSet(BaseRQViewSet):
"""
Retrieve a list of RQ Workers.
"""
serializer_class = serializers.BackgroundWorkerSerializer
lookup_field = 'name'
def get_view_name(self):
return "Background Workers"
def get_data(self):
config = QUEUES_LIST[0]
return Worker.all(get_redis_connection(config['connection_config']))
def retrieve(self, request, name):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
workers = Worker.all(get_redis_connection(config['connection_config']))
worker = next((item for item in workers if item.name == name), None)
if not worker:
raise Http404
serializer = serializers.BackgroundWorkerSerializer(worker, context={'request': request})
return Response(serializer.data)
class BackgroundTaskViewSet(BaseRQViewSet):
"""
Retrieve a list of RQ Tasks.
"""
serializer_class = serializers.BackgroundTaskSerializer
def get_view_name(self):
return "Background Tasks"
def get_data(self):
return get_rq_jobs()
def get_task_from_id(self, task_id):
config = QUEUES_LIST[0]
task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config']))
if not task:
raise Http404
return task
@extend_schema(responses={200: OpenApiTypes.OBJECT})
def retrieve(self, request, pk):
"""
Retrieve the details of the specified RQ Task.
"""
task = self.get_task_from_id(pk)
serializer = self.serializer_class(task, context={'request': request})
return Response(serializer.data)
@action(methods=["POST"], detail=True)
def delete(self, request, pk):
"""
Delete the specified RQ Task.
"""
delete_rq_job(pk)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def requeue(self, request, pk):
"""
Requeues the specified RQ Task.
"""
requeue_rq_job(pk)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def enqueue(self, request, pk):
"""
Enqueues the specified RQ Task.
"""
enqueue_rq_job(pk)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def stop(self, request, pk):
"""
Stops the specified RQ Task.
"""
stopped_jobs = stop_rq_job(pk)
if len(stopped_jobs) == 1:
return HttpResponse(status=200)
else:
return HttpResponse(status=204)

View File

@@ -1,4 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.migrations.operations import AlterModelOptions
@@ -16,9 +18,14 @@ class CoreConfig(AppConfig):
name = "core"
def ready(self):
from core.api import schema # noqa
from core.api import schema # noqa: F401
from netbox.models.features import register_models
from . import data_backends, search
from . import data_backends, events, search # noqa: F401
from netbox import context_managers # noqa: F401
# Register models
register_models(*self.get_models())
# Clear Redis cache on startup in development mode
if settings.DEBUG:
cache.clear()

View File

@@ -59,8 +59,45 @@ class JobStatusChoices(ChoiceSet):
(STATUS_FAILED, _('Failed'), 'red'),
)
ENQUEUED_STATE_CHOICES = (
STATUS_PENDING,
STATUS_SCHEDULED,
STATUS_RUNNING,
)
TERMINAL_STATE_CHOICES = (
STATUS_COMPLETED,
STATUS_ERRORED,
STATUS_FAILED,
)
class JobIntervalChoices(ChoiceSet):
INTERVAL_MINUTELY = 1
INTERVAL_HOURLY = 60
INTERVAL_DAILY = 60 * 24
INTERVAL_WEEKLY = 60 * 24 * 7
CHOICES = (
(INTERVAL_MINUTELY, _('Minutely')),
(INTERVAL_HOURLY, _('Hourly')),
(INTERVAL_DAILY, _('Daily')),
(INTERVAL_WEEKLY, _('Weekly')),
)
#
# ObjectChanges
#
class ObjectChangeActionChoices(ChoiceSet):
ACTION_CREATE = 'create'
ACTION_UPDATE = 'update'
ACTION_DELETE = 'delete'
CHOICES = (
(ACTION_CREATE, _('Created'), 'green'),
(ACTION_UPDATE, _('Updated'), 'blue'),
(ACTION_DELETE, _('Deleted'), 'red'),
)

View File

@@ -8,10 +8,13 @@ from urllib.parse import urlparse
from django import forms
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _
from netbox.data_backends import DataBackend
from netbox.utils import register_data_backend
from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
from utilities.socks import ProxyPoolManager
from .exceptions import SyncError
__all__ = (
@@ -31,7 +34,7 @@ class LocalBackend(DataBackend):
@contextmanager
def fetch(self):
logger.debug(f"Data source type is local; skipping fetch")
logger.debug("Data source type is local; skipping fetch")
local_path = urlparse(self.url).path # Strip file:// scheme
yield local_path
@@ -67,11 +70,18 @@ class GitBackend(DataBackend):
# Initialize backend config
config = ConfigDict()
self.use_socks = False
# Apply HTTP proxy (if configured)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
if settings.HTTP_PROXIES:
if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None):
if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
if self.url_scheme in ('http', 'https'):
config.set("http", "proxy", proxy)
if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
self.use_socks = True
return config
@@ -84,11 +94,13 @@ class GitBackend(DataBackend):
clone_args = {
"branch": self.params.get('branch'),
"config": self.config,
"depth": 1,
"errstream": porcelain.NoneStream(),
"quiet": True,
}
# check if using socks for proxy - if so need to use custom pool_manager
if self.use_socks:
clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme))
if self.url_scheme in ('http', 'https'):
if self.params.get('username'):
clone_args.update(
@@ -97,6 +109,9 @@ class GitBackend(DataBackend):
"password": self.params.get('password'),
}
)
if self.url_scheme:
clone_args["quiet"] = True
clone_args["depth"] = 1
logger.debug(f"Cloning git repo: {self.url}")
try:

33
netbox/core/events.py Normal file
View File

@@ -0,0 +1,33 @@
from django.utils.translation import gettext as _
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
__all__ = (
'JOB_COMPLETED',
'JOB_ERRORED',
'JOB_FAILED',
'JOB_STARTED',
'OBJECT_CREATED',
'OBJECT_DELETED',
'OBJECT_UPDATED',
)
# Object events
OBJECT_CREATED = 'object_created'
OBJECT_UPDATED = 'object_updated'
OBJECT_DELETED = 'object_deleted'
# Job events
JOB_STARTED = 'job_started'
JOB_COMPLETED = 'job_completed'
JOB_FAILED = 'job_failed'
JOB_ERRORED = 'job_errored'
# Register core events
EventType(OBJECT_CREATED, _('Object created')).register()
EventType(OBJECT_UPDATED, _('Object updated')).register()
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
EventType(JOB_STARTED, _('Job started')).register()
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
@@ -5,6 +6,8 @@ import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.filters import ContentTypeFilter
from .choices import *
from .models import *
@@ -13,6 +16,7 @@ __all__ = (
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
'ObjectChangeFilterSet',
)
@@ -126,6 +130,43 @@ class JobFilterSet(BaseFilterSet):
)
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label=_('User name'),
)
class Meta:
model = ObjectChange
fields = (
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -1,5 +1,4 @@
from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from core.choices import *
@@ -7,8 +6,11 @@ from core.models import *
from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@@ -17,6 +19,7 @@ __all__ = (
'DataFileFilterForm',
'DataSourceFilterForm',
'JobFilterForm',
'ObjectChangeFilterForm',
)
@@ -118,12 +121,46 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
widget=DateTimePicker()
)
user = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
queryset=User.objects.all(),
required=False,
label=_('User')
)
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
model = ObjectChange
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('time_before', 'time_after', name=_('Time')),
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
)
time_after = forms.DateTimeField(
required=False,
label=_('After'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
label=_('Action'),
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User')
)
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
)
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),

View File

@@ -6,6 +6,7 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = (
'DataFileFilter',
'DataSourceFilter',
'ObjectChangeFilter',
)
@@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
@autotype_decorator(filtersets.DataSourceFilterSet)
class DataSourceFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.ObjectChange, lookups=True)
@autotype_decorator(filtersets.ObjectChangeFilterSet)
class ObjectChangeFilter(BaseFilterMixin):
pass

View File

@@ -0,0 +1,24 @@
from typing import Annotated, List
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from core.models import ObjectChange
__all__ = (
'ChangelogMixin',
)
@strawberry.type
class ChangelogMixin:
@strawberry_django.field
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: # noqa: F821
content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter(
changed_object_type=content_type,
changed_object_id=self.pk
)
return object_changes.restrict(info.context.request.user, 'view')

View File

@@ -3,18 +3,13 @@ from typing import List
import strawberry
import strawberry_django
from core import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class CoreQuery:
@strawberry.field
def data_file(self, id: int) -> DataFileType:
return models.DataFile.objects.get(pk=id)
data_file: DataFileType = strawberry_django.field()
data_file_list: List[DataFileType] = strawberry_django.field()
@strawberry.field
def data_source(self, id: int) -> DataSourceType:
return models.DataSource.objects.get(pk=id)
data_source: DataSourceType = strawberry_django.field()
data_source_list: List[DataSourceType] = strawberry_django.field()

View File

@@ -10,6 +10,7 @@ from .filters import *
__all__ = (
'DataFileType',
'DataSourceType',
'ObjectChangeType',
)
@@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
class DataSourceType(NetBoxObjectType):
datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
@strawberry_django.type(
models.ObjectChange,
fields='__all__',
filters=ObjectChangeFilter
)
class ObjectChangeType(BaseObjectType):
pass

View File

@@ -1,33 +1,33 @@
import logging
from netbox.jobs import JobRunner
from netbox.search.backends import search_backend
from .choices import *
from .choices import DataSourceStatusChoices
from .exceptions import SyncError
from .models import DataSource
from rq.timeouts import JobTimeoutException
logger = logging.getLogger(__name__)
def sync_datasource(job, *args, **kwargs):
class SyncDataSourceJob(JobRunner):
"""
Call sync() on a DataSource.
"""
datasource = DataSource.objects.get(pk=job.object_id)
try:
job.start()
datasource.sync()
class Meta:
name = 'Synchronization'
# Update the search cache for DataFiles belonging to this source
search_backend.cache(datasource.datafiles.iterator())
def run(self, *args, **kwargs):
datasource = DataSource.objects.get(pk=self.job.object_id)
job.terminate()
try:
datasource.sync()
except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) in (SyncError, JobTimeoutException):
logging.error(e)
else:
# Update the search cache for DataFiles belonging to this source
search_backend.cache(datasource.datafiles.iterator())
except Exception as e:
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) is SyncError:
logging.error(e)
raise e

View File

@@ -5,12 +5,16 @@ import sys
from django import get_version
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from core.models import ObjectType
from users.models import User
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
EXCLUDE_MODELS = (
'extras.branch',
'extras.stagedchange',
)
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}
@@ -18,7 +22,7 @@ BANNER_TEXT = """### NetBox interactive shell ({node})
node=platform.node(),
python=platform.python_version(),
django=get_version(),
netbox=settings.VERSION
netbox=settings.RELEASE.name
)
@@ -44,12 +48,16 @@ class Command(BaseCommand):
# Gather Django models and constants from each app
for app in APPS:
self.django_models[app] = []
models = []
# Load models from each app
for model in apps.get_app_config(app).get_models():
namespace[model.__name__] = model
self.django_models[app].append(model.__name__)
app_label = model._meta.app_label
model_name = model._meta.model_name
if f'{app_label}.{model_name}' not in EXCLUDE_MODELS:
namespace[model.__name__] = model
models.append(model.__name__)
self.django_models[app] = sorted(models)
# Constants
try:
@@ -61,7 +69,7 @@ class Command(BaseCommand):
# Additional objects to include
namespace['ObjectType'] = ObjectType
namespace['User'] = get_user_model()
namespace['User'] = User
# Load convenience commands
namespace.update({

View File

@@ -2,6 +2,8 @@ import logging
from django_rq.management.commands.rqworker import Command as _Command
from netbox.registry import registry
DEFAULT_QUEUES = ('high', 'default', 'low')
@@ -14,6 +16,15 @@ class Command(_Command):
of only the 'default' queue).
"""
def handle(self, *args, **options):
# Setup system jobs.
for job, kwargs in registry['system_jobs'].items():
try:
interval = kwargs['interval']
except KeyError:
raise TypeError("System job must specify an interval (in minutes).")
logger.debug(f"Scheduling system job {job.name} (interval={interval})")
job.enqueue_once(**kwargs)
# Run the worker with scheduler functionality
options['with_scheduler'] = True

View File

@@ -26,7 +26,7 @@ class Command(BaseCommand):
if invalid_names := set(options['name']) - found_names:
raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
else:
raise CommandError(f"Must specify at least one data source, or set --all.")
raise CommandError("Must specify at least one data source, or set --all.")
if len(options['name']) > 1:
self.stdout.write(f"Syncing {len(datasources)} data sources.")
@@ -43,4 +43,4 @@ class Command(BaseCommand):
raise e
if len(options['name']) > 1:
self.stdout.write(f"Finished.")
self.stdout.write("Finished.")

View File

@@ -8,13 +8,12 @@ import utilities.json
class Migration(migrations.Migration):
replaces = [
('core', '0001_initial'),
('core', '0002_managedfile'),
('core', '0003_job'),
('core', '0004_replicate_jobresults'),
('core', '0005_job_created_auto_now')
('core', '0005_job_created_auto_now'),
]
dependencies = [
@@ -30,7 +29,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
@@ -55,9 +57,28 @@ class Migration(migrations.Migration):
('last_updated', models.DateTimeField(editable=False)),
('path', models.CharField(editable=False, max_length=1000)),
('size', models.PositiveIntegerField(editable=False)),
('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])),
(
'hash',
models.CharField(
editable=False,
max_length=64,
validators=[
django.core.validators.RegexValidator(
message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$'
)
],
),
),
('data', models.BinaryField()),
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='datafiles', to='core.datasource')),
(
'source',
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
related_name='datafiles',
to='core.datasource',
),
),
],
options={
'ordering': ('source', 'path'),
@@ -76,8 +97,18 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.PositiveBigIntegerField()),
('datafile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile')),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
(
'datafile',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile'
),
),
(
'object_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype'
),
),
],
options={
'indexes': [models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx')],
@@ -97,8 +128,26 @@ class Migration(migrations.Migration):
('last_updated', models.DateTimeField(blank=True, editable=False, null=True)),
('file_root', models.CharField(max_length=1000)),
('file_path', models.FilePathField(editable=False)),
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
(
'data_file',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='core.datafile',
),
),
(
'data_source',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='core.datasource',
),
),
('auto_sync_enabled', models.BooleanField(default=False)),
],
options={
@@ -108,7 +157,9 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='managedfile',
constraint=models.UniqueConstraint(fields=('file_root', 'file_path'), name='core_managedfile_unique_root_path'),
constraint=models.UniqueConstraint(
fields=('file_root', 'file_path'), name='core_managedfile_unique_root_path'
),
),
migrations.CreateModel(
name='Job',
@@ -118,14 +169,33 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200)),
('created', models.DateTimeField()),
('scheduled', models.DateTimeField(blank=True, null=True)),
('interval', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])),
(
'interval',
models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
('started', models.DateTimeField(blank=True, null=True)),
('completed', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(default='pending', max_length=30)),
('data', models.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=True)),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
(
'object_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype'
),
),
(
'user',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'ordering': ['-created'],

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_job_created_auto_now'),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_datasource_type_remove_choices'),
]

View File

@@ -3,7 +3,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0007_job_add_error_field'),
@@ -12,8 +11,7 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='ObjectType',
fields=[
],
fields=[],
options={
'proxy': True,
'indexes': [],

View File

@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_contenttype_proxy'),
]

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_configrevision'),
]

View File

@@ -0,0 +1,78 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0010_gfk_indexes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='ObjectChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('time', models.DateTimeField(auto_now_add=True, db_index=True)),
('user_name', models.CharField(editable=False, max_length=150)),
('request_id', models.UUIDField(db_index=True, editable=False)),
('action', models.CharField(max_length=50)),
('changed_object_id', models.PositiveBigIntegerField()),
('related_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
('object_repr', models.CharField(editable=False, max_length=200)),
('prechange_data', models.JSONField(blank=True, editable=False, null=True)),
('postchange_data', models.JSONField(blank=True, editable=False, null=True)),
(
'changed_object_type',
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
),
),
(
'related_object_type',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
),
),
(
'user',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='changes',
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'verbose_name': 'object change',
'verbose_name_plural': 'object changes',
'ordering': ['-time'],
'indexes': [
models.Index(
fields=['changed_object_type', 'changed_object_id'],
name='core_object_changed_c227ce_idx',
),
models.Index(
fields=['related_object_type', 'related_object_id'],
name='core_object_related_3375d6_idx',
),
],
},
),
],
# Table has been renamed from 'extras' app
database_operations=[],
),
]

View File

@@ -0,0 +1,23 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0011_move_objectchange'),
]
operations = [
migrations.AlterField(
model_name='job',
name='object_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='jobs',
to='contenttypes.contenttype',
),
),
]

View File

@@ -1,5 +1,6 @@
from .config import *
from .contenttypes import *
from .change_logging import *
from .config import *
from .data import *
from .files import *
from .jobs import *

View File

@@ -1,13 +1,18 @@
from functools import cached_property
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.models import ObjectType
from extras.choices import *
from ..querysets import ObjectChangeQuerySet
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from .contenttypes import ObjectType
__all__ = (
'ObjectChange',
@@ -131,11 +136,76 @@ class ObjectChange(models.Model):
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
return reverse('core:objectchange', args=[self.pk])
def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)
@property
@cached_property
def has_changes(self):
return self.prechange_data != self.postchange_data
@cached_property
def diff_exclude_fields(self):
"""
Return a set of attributes which should be ignored when calculating a diff
between the pre- and post-change data. (For instance, it would not make
sense to compare the "last updated" times as these are expected to differ.)
"""
model = self.changed_object_type.model_class()
attrs = set()
# Exclude auto-populated change tracking fields
if issubclass(model, ChangeLoggingMixin):
attrs.update({'created', 'last_updated'})
# Exclude MPTT-internal fields
if issubclass(model, MPTTModel):
attrs.update({'level', 'lft', 'rght', 'tree_id'})
return attrs
def get_clean_data(self, prefix):
"""
Return only the pre-/post-change attributes which are relevant for calculating a diff.
"""
ret = {}
change_data = getattr(self, f'{prefix}_data') or {}
for k, v in change_data.items():
if k not in self.diff_exclude_fields and not k.startswith('_'):
ret[k] = v
return ret
@cached_property
def prechange_data_clean(self):
return self.get_clean_data('prechange')
@cached_property
def postchange_data_clean(self):
return self.get_clean_data('postchange')
def diff(self):
"""
Return a dictionary of pre- and post-change values for attributes which have changed.
"""
prechange_data = self.prechange_data_clean
postchange_data = self.postchange_data_clean
# Determine which attributes have changed
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
changed_attrs = sorted(postchange_data.keys())
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
changed_attrs = sorted(prechange_data.keys())
else:
# TODO: Support deep (recursive) comparison
changed_data = shallow_compare_dict(prechange_data, postchange_data)
changed_attrs = sorted(changed_data.keys())
return {
'pre': {
k: prechange_data.get(k) for k in changed_attrs
},
'post': {
k: postchange_data.get(k) for k in changed_attrs
},
}

View File

@@ -1,10 +1,10 @@
import hashlib
import logging
import os
import yaml
from fnmatch import fnmatchcase
from urllib.parse import urlparse
import yaml
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
@@ -12,7 +12,6 @@ from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@@ -22,8 +21,6 @@ from netbox.registry import registry
from utilities.querysets import RestrictedQuerySet
from ..choices import *
from ..exceptions import SyncError
from ..signals import post_sync, pre_sync
from .jobs import Job
__all__ = (
'AutoSyncRecord',
@@ -87,9 +84,6 @@ class DataSource(JobsMixin, PrimaryModel):
def __str__(self):
return f'{self.name}'
def get_absolute_url(self):
return reverse('core:datasource', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
@@ -128,7 +122,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
})
def to_objectchange(self, action):
@@ -153,21 +147,6 @@ class DataSource(JobsMixin, PrimaryModel):
return objectchange
def enqueue_sync_job(self, request):
"""
Enqueue a background job to synchronize the DataSource by calling sync().
"""
# Set the status to "syncing"
self.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Enqueue a sync job
return Job.enqueue(
import_string('core.jobs.sync_datasource'),
instance=self,
user=request.user
)
def get_backend(self):
backend_params = self.parameters or {}
return self.backend_class(self.source_url, **backend_params)
@@ -176,6 +155,8 @@ class DataSource(JobsMixin, PrimaryModel):
"""
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
"""
from core.signals import post_sync, pre_sync
if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
@@ -217,7 +198,7 @@ class DataSource(JobsMixin, PrimaryModel):
logger.debug(f"Updated {updated_count} files")
# Bulk delete deleted files
deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
deleted_count, __ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
logger.debug(f"Deleted {deleted_count} files")
# Walk the local replication to find new files

View File

@@ -93,9 +93,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
self.file_path = os.path.basename(self.data_path)
# Ensure that the file root and path make a unique pair
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
if self._meta.model.objects.filter(
file_root=self.file_root, file_path=self.file_path
).exclude(pk=self.pk).exists():
raise ValidationError(
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
_("A {model} with this file path already exists ({path}).").format(
model=self._meta.verbose_name.lower(),
path=f"{self.file_root}/{self.file_path}"
))
def delete(self, *args, **kwargs):
# Delete file from disk

View File

@@ -9,13 +9,11 @@ from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices
from core.models import ObjectType
from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model
@@ -32,6 +30,8 @@ class Job(models.Model):
to='contenttypes.ContentType',
related_name='jobs',
on_delete=models.CASCADE,
blank=True,
null=True
)
object_id = models.PositiveBigIntegerField(
blank=True,
@@ -117,10 +117,11 @@ class Job(models.Model):
def get_absolute_url(self):
# TODO: Employ dynamic registration
if self.object_type.model == 'reportmodule':
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk})
if self.object_type.model == 'scriptmodule':
return reverse(f'extras:script_result', kwargs={'job_pk': self.pk})
if self.object_type:
if self.object_type.model == 'reportmodule':
return reverse('extras:report_result', kwargs={'job_pk': self.pk})
elif self.object_type.model == 'scriptmodule':
return reverse('extras:script_result', kwargs={'job_pk': self.pk})
return reverse('core:job', args=[self.pk])
def get_status_color(self):
@@ -130,7 +131,7 @@ class Job(models.Model):
super().clean()
# Validate the assigned object type
if self.object_type not in ObjectType.objects.with_feature('jobs'):
if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'):
raise ValidationError(
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
@@ -153,12 +154,16 @@ class Job(models.Model):
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id))
if job:
job.cancel()
try:
job.cancel()
except InvalidJobOperation:
# Job may raise this exception from get_status() if missing from Redis
pass
def start(self):
"""
@@ -198,25 +203,44 @@ class Job(models.Model):
job_end.send(self)
@classmethod
def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=None, **kwargs):
def enqueue(
cls,
func,
instance=None,
name='',
user=None,
schedule_at=None,
interval=None,
immediate=False,
**kwargs
):
"""
Create a Job instance and enqueue a job using the given callable
Args:
func: The callable object to be enqueued for execution
instance: The NetBox object to which this job pertains
instance: The NetBox object to which this job pertains (optional)
name: Name for the job (optional)
user: The user responsible for running the job
schedule_at: Schedule the job to be executed at the passed date and time
interval: Recurrence interval (in minutes)
immediate: Run the job immediately without scheduling it in the background. Should be used for interactive
management commands only.
"""
object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False)
rq_queue_name = get_queue_for_model(object_type.model)
if schedule_at and immediate:
raise ValueError(_("enqueue() cannot be called with values for both schedule_at and immediate."))
if instance:
object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False)
object_id = instance.pk
else:
object_type = object_id = None
rq_queue_name = get_queue_for_model(object_type.model if object_type else None)
queue = django_rq.get_queue(rq_queue_name)
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
job = Job.objects.create(
job = Job(
object_type=object_type,
object_id=instance.pk,
object_id=object_id,
name=name,
status=status,
scheduled=schedule_at,
@@ -224,9 +248,19 @@ class Job(models.Model):
user=user,
job_id=uuid.uuid4()
)
job.full_clean()
job.save()
if schedule_at:
# Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous
# (blocking) operation, and execution will pause until the job completes.
if immediate:
func(job_id=str(job.job_id), job=job, **kwargs)
# Schedule the job to run at a specific date & time.
elif schedule_at:
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
# Schedule the job to run asynchronously at this first available opportunity.
else:
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)

216
netbox/core/plugins.py Normal file
View File

@@ -0,0 +1,216 @@
import datetime
import importlib
import importlib.util
from dataclasses import dataclass, field
from typing import Optional
import requests
from django.conf import settings
from django.core.cache import cache
from netbox.plugins import PluginConfig
from utilities.datetime import datetime_from_timestamp
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed'
@dataclass
class PluginAuthor:
"""
Identifying information for the author of a plugin.
"""
name: str
org_id: str = ''
url: str = ''
@dataclass
class PluginVersion:
"""
Details for a specific versioned release of a plugin.
"""
date: datetime.datetime = None
version: str = ''
netbox_min_version: str = ''
netbox_max_version: str = ''
has_model: bool = False
is_certified: bool = False
is_feature: bool = False
is_integration: bool = False
is_netboxlabs_supported: bool = False
@dataclass
class Plugin:
"""
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
tag_line: str = ''
description_short: str = ''
slug: str = ''
author: Optional[PluginAuthor] = None
created_at: datetime.datetime = None
updated_at: datetime.datetime = None
license_type: str = ''
homepage_url: str = ''
package_name_pypi: str = ''
config_name: str = ''
is_certified: bool = False
release_latest: PluginVersion = field(default_factory=PluginVersion)
release_recent_history: list[PluginVersion] = field(default_factory=list)
is_local: bool = False # extra field for locally installed plugins
is_installed: bool = False
installed_version: str = ''
def get_local_plugins(plugins=None):
"""
Return a dictionary of all locally-installed plugins, mapped by name.
"""
plugins = plugins or {}
local_plugins = {}
# Gather all locally-installed plugins
for plugin_name in settings.PLUGINS:
plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config
local_plugins[plugin_config.name] = Plugin(
config_name=plugin_config.name,
title_short=plugin_config.verbose_name,
title_long=plugin_config.verbose_name,
tag_line=plugin_config.description,
description_short=plugin_config.description,
is_local=True,
is_installed=True,
installed_version=plugin_config.version,
)
# Update catalog entries for local plugins, or add them to the list if not listed
for k, v in local_plugins.items():
if k in plugins:
plugins[k].is_local = True
plugins[k].is_installed = True
plugins[k].installed_version = v.installed_version
else:
plugins[k] = v
return plugins
def get_catalog_plugins():
"""
Return a dictionary of all entries in the plugins catalog, mapped by name.
"""
session = requests.Session()
# Disable catalog fetching for isolated deployments
if settings.ISOLATED_DEPLOYMENT:
return {}
def get_pages():
# TODO: pagination is currently broken in API
payload = {'page': '1', 'per_page': '50'}
first_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield first_page
num_pages = first_page['metadata']['pagination']['last_page']
for page in range(2, num_pages + 1):
payload['page'] = page
next_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield next_page
def make_plugin_dict():
plugins = {}
for page in get_pages():
for data in page['data']:
# Populate releases
releases = []
for version in data['release_recent_history']:
releases.append(
PluginVersion(
date=datetime_from_timestamp(version['date']),
version=version['version'],
netbox_min_version=version['netbox_min_version'],
netbox_max_version=version['netbox_max_version'],
has_model=version['has_model'],
is_certified=version['is_certified'],
is_feature=version['is_feature'],
is_integration=version['is_integration'],
is_netboxlabs_supported=version['is_netboxlabs_supported'],
)
)
releases = sorted(releases, key=lambda x: x.date, reverse=True)
latest_release = PluginVersion(
date=datetime_from_timestamp(data['release_latest']['date']),
version=data['release_latest']['version'],
netbox_min_version=data['release_latest']['netbox_min_version'],
netbox_max_version=data['release_latest']['netbox_max_version'],
has_model=data['release_latest']['has_model'],
is_certified=data['release_latest']['is_certified'],
is_feature=data['release_latest']['is_feature'],
is_integration=data['release_latest']['is_integration'],
is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'],
)
# Populate author (if any)
if data['author']:
author = PluginAuthor(
name=data['author']['name'],
org_id=data['author']['org_id'],
url=data['author']['url'],
)
else:
author = None
# Populate plugin data
plugins[data['config_name']] = Plugin(
id=data['id'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],
tag_line=data['tag_line'],
description_short=data['description_short'],
slug=data['slug'],
author=author,
created_at=datetime_from_timestamp(data['created_at']),
updated_at=datetime_from_timestamp(data['updated_at']),
license_type=data['license_type'],
homepage_url=data['homepage_url'],
package_name_pypi=data['package_name_pypi'],
config_name=data['config_name'],
is_certified=data['is_certified'],
release_latest=latest_release,
release_recent_history=releases,
)
return plugins
catalog_plugins = cache.get(CACHE_KEY_CATALOG_FEED, default={})
if not catalog_plugins:
try:
catalog_plugins = make_plugin_dict()
cache.set(CACHE_KEY_CATALOG_FEED, catalog_plugins, 3600)
except requests.exceptions.RequestException:
pass
return catalog_plugins

26
netbox/core/querysets.py Normal file
View File

@@ -0,0 +1,26 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db.utils import ProgrammingError
from utilities.querysets import RestrictedQuerySet
__all__ = (
'ObjectChangeQuerySet',
)
class ObjectChangeQuerySet(RestrictedQuerySet):
def valid_models(self):
# Exclude any change records which refer to an instance of a model that's no longer installed. This
# can happen when a plugin is removed but its data remains in the database, for example.
try:
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
except ProgrammingError:
# Handle the case where the database schema has not yet been initialized
content_types = ContentType.objects.none()
content_type_ids = set(
ct.pk for ct in content_types
)
return self.filter(changed_object_type_id__in=content_type_ids)

View File

@@ -1,9 +1,26 @@
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.choices import ObjectChangeActionChoices
from core.events import *
from core.models import ObjectChange
from extras.events import enqueue_event
from extras.utils import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from utilities.exceptions import AbortRequest
from .models import ConfigRevision
__all__ = (
'clear_events',
'job_end',
'job_start',
'post_sync',
@@ -18,6 +35,152 @@ job_end = Signal()
pre_sync = Signal()
post_sync = Signal()
# Event signals
clear_events = Signal()
#
# Change logging & event handling
#
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
m2m_changed = False
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Determine the type of change being made
if kwargs.get('created'):
event_type = OBJECT_CREATED
elif 'created' in kwargs:
event_type = OBJECT_UPDATED
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
# m2m_changed with objects added or removed
m2m_changed = True
event_type = OBJECT_UPDATED
else:
return
# Create/update an ObjectChange record for this change
action = {
OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
}[event_type]
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
if m2m_changed and (
prev_change := ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).first()
):
prev_change.postchange_data = objectchange.postchange_data
prev_change.save()
elif objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Ensure that we're working with fresh M2M assignments
if m2m_changed:
instance.refresh_from_db()
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, event_type)
events_queue.set(queue)
# Increment metric counters
if event_type == OBJECT_CREATED:
model_inserts.labels(instance._meta.model_name).inc()
elif event_type == OBJECT_UPDATED:
model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
events_queue.set(queue)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""
Delete any queued events (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
events_queue.set({})
#
# DataSource handlers
#
@receiver(post_sync)
def auto_sync(instance, **kwargs):

View File

@@ -1,3 +1,4 @@
from .change_logging import *
from .config import *
from .data import *
from .jobs import *

View File

@@ -0,0 +1,53 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from core.models import ObjectChange
from netbox.tables import NetBoxTable, columns
from .template_code import *
__all__ = (
'ObjectChangeTable',
)
class ObjectChangeTable(NetBoxTable):
time = columns.DateTimeColumn(
verbose_name=_('Time'),
timespec='minutes',
linkify=True
)
user_name = tables.Column(
verbose_name=_('Username')
)
full_name = tables.TemplateColumn(
accessor=tables.A('user'),
template_code=OBJECTCHANGE_FULL_NAME,
verbose_name=_('Full Name'),
orderable=False
)
action = columns.ChoiceFieldColumn(
verbose_name=_('Action'),
)
changed_object_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
object_repr = tables.TemplateColumn(
accessor=tables.A('changed_object'),
template_code=OBJECTCHANGE_OBJECT,
verbose_name=_('Object'),
orderable=False
)
request_id = tables.TemplateColumn(
template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name=_('Request ID')
)
actions = columns.ActionsColumn(
actions=()
)
class Meta(NetBoxTable.Meta):
model = ObjectChange
fields = (
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions',
)

View File

@@ -19,6 +19,7 @@ REVISION_BUTTONS = """
class ConfigRevisionTable(NetBoxTable):
is_active = columns.BooleanColumn(
verbose_name=_('Is Active'),
false_mark=None
)
actions = columns.ActionsColumn(
actions=('delete',),

View File

@@ -1,39 +1,84 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable
from netbox.tables import BaseTable, columns
__all__ = (
'PluginTable',
'CatalogPluginTable',
'PluginVersionTable',
)
class PluginTable(BaseTable):
name = tables.Column(
accessor=tables.A('verbose_name'),
verbose_name=_('Name')
)
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
)
package = tables.Column(
accessor=tables.A('name'),
verbose_name=_('Package')
last_updated = columns.DateTimeColumn(
accessor=tables.A('date'),
timespec='minutes',
verbose_name=_('Last Updated')
)
author = tables.Column(
verbose_name=_('Author')
min_version = tables.Column(
accessor=tables.A('netbox_min_version'),
verbose_name=_('Minimum NetBox Version')
)
author_email = tables.Column(
verbose_name=_('Author Email')
)
description = tables.Column(
verbose_name=_('Description')
max_version = tables.Column(
accessor=tables.A('netbox_max_version'),
verbose_name=_('Maximum NetBox Version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugins found')
empty_text = _('No plugin data found')
fields = (
'name', 'version', 'package', 'author', 'author_email', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
default_columns = (
'name', 'version', 'package', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
orderable = False
class CatalogPluginTable(BaseTable):
title_long = tables.Column(
linkify=('core:plugin', [tables.A('config_name')]),
verbose_name=_('Name')
)
author = tables.Column(
accessor=tables.A('author__name'),
verbose_name=_('Author')
)
is_local = columns.BooleanColumn(
verbose_name=_('Local')
)
is_installed = columns.BooleanColumn(
verbose_name=_('Installed')
)
is_certified = columns.BooleanColumn(
verbose_name=_('Certified')
)
created_at = columns.DateTimeColumn(
verbose_name=_('Published')
)
updated_at = columns.DateTimeColumn(
verbose_name=_('Updated')
)
installed_version = tables.Column(
verbose_name=_('Installed Version')
)
latest_version = tables.Column(
accessor=tables.A('release_latest__version'),
verbose_name=_('Latest Version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugin data found')
fields = (
'title_long', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at',
'installed_version', 'latest_version',
)
default_columns = (
'title_long', 'author', 'is_local', 'is_installed', 'is_certified', 'installed_version', 'latest_version',
)
# List installed plugins first, then certified plugins, then
# everything else (with each tranche ordered alphabetically)
order_by = ('-is_installed', '-is_certified', 'name')

View File

@@ -0,0 +1,16 @@
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ value.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if value and value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
"""

View File

@@ -1,7 +1,14 @@
import uuid
from django_rq import get_queue
from django_rq.workers import get_worker
from django.urls import reverse
from django.utils import timezone
from rq.job import Job as RQ_Job, JobStatus
from rq.registry import FailedJobRegistry, StartedJobRegistry
from utilities.testing import APITestCase, APIViewTestCases
from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase
from ..models import *
@@ -57,6 +64,7 @@ class DataFileTest(
):
model = DataFile
brief_fields = ['display', 'id', 'path', 'url']
user_permissions = ('core.view_datasource', )
@classmethod
def setUpTestData(cls):
@@ -90,3 +98,164 @@ class DataFileTest(
),
)
DataFile.objects.bulk_create(data_files)
class BackgroundTaskTestCase(TestCase):
user_permissions = ()
@staticmethod
def dummy_job_default():
return "Job finished"
@staticmethod
def dummy_job_failing():
raise Exception("Job failed")
def setUp(self):
"""
Create a user and token for API calls.
"""
# Create the test user and assign permissions
self.user = User.objects.create_user(username='testuser')
self.user.is_staff = True
self.user.is_active = True
self.user.save()
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'}
# Clear all queues prior to running each test
get_queue('default').connection.flushall()
get_queue('high').connection.flushall()
get_queue('low').connection.flushall()
def test_background_queue_list(self):
url = reverse('core-api:rqqueue-list')
# Attempt to load view without permission
self.user.is_staff = False
self.user.save()
response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 403)
# Load view with permission
self.user.is_staff = True
self.user.save()
response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn('default', str(response.content))
self.assertIn('high', str(response.content))
self.assertIn('low', str(response.content))
def test_background_queue(self):
response = self.client.get(reverse('core-api:rqqueue-detail', args=['default']), **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn('default', str(response.content))
self.assertIn('oldest_job_timestamp', str(response.content))
self.assertIn('scheduled_jobs', str(response.content))
def test_background_task_list(self):
queue = get_queue('default')
queue.enqueue(self.dummy_job_default)
response = self.client.get(reverse('core-api:rqtask-list'), **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn('origin', str(response.content))
self.assertIn('core.tests.test_api.BackgroundTaskTestCase.dummy_job_default()', str(response.content))
def test_background_task(self):
queue = get_queue('default')
job = queue.enqueue(self.dummy_job_default)
response = self.client.get(reverse('core-api:rqtask-detail', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn(str(job.id), str(response.content))
self.assertIn('origin', str(response.content))
self.assertIn('meta', str(response.content))
self.assertIn('kwargs', str(response.content))
def test_background_task_delete(self):
queue = get_queue('default')
job = queue.enqueue(self.dummy_job_default)
response = self.client.post(reverse('core-api:rqtask-delete', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
self.assertFalse(RQ_Job.exists(job.id, connection=queue.connection))
queue = get_queue('default')
self.assertNotIn(job.id, queue.job_ids)
def test_background_task_requeue(self):
queue = get_queue('default')
# Enqueue & run a job that will fail
job = queue.enqueue(self.dummy_job_failing)
worker = get_worker('default')
worker.work(burst=True)
self.assertTrue(job.is_failed)
# Re-enqueue the failed job and check that its status has been reset
response = self.client.post(reverse('core-api:rqtask-requeue', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
job = RQ_Job.fetch(job.id, queue.connection)
self.assertFalse(job.is_failed)
def test_background_task_enqueue(self):
queue = get_queue('default')
# Enqueue some jobs that each depends on its predecessor
job = previous_job = None
for _ in range(0, 3):
job = queue.enqueue(self.dummy_job_default, depends_on=previous_job)
previous_job = job
# Check that the last job to be enqueued has a status of deferred
self.assertIsNotNone(job)
self.assertEqual(job.get_status(), JobStatus.DEFERRED)
self.assertIsNone(job.enqueued_at)
# Force-enqueue the deferred job
response = self.client.post(reverse('core-api:rqtask-enqueue', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
# Check that job's status is updated correctly
job = queue.fetch_job(job.id)
self.assertEqual(job.get_status(), JobStatus.QUEUED)
self.assertIsNotNone(job.enqueued_at)
def test_background_task_stop(self):
queue = get_queue('default')
worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job)
self.assertEqual(job.get_status(), JobStatus.STARTED)
response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
self.assertEqual(response.status_code, 200)
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
self.assertEqual(len(started_job_registry), 0)
canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)
self.assertEqual(len(canceled_job_registry), 1)
self.assertIn(job.id, canceled_job_registry)
def test_worker_list(self):
worker1 = get_worker('default', name=uuid.uuid4().hex)
worker1.register_birth()
worker2 = get_worker('high')
worker2.register_birth()
response = self.client.get(reverse('core-api:rqworker-list'), **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn(str(worker1.name), str(response.content))
def test_worker(self):
worker1 = get_worker('default', name=uuid.uuid4().hex)
worker1.register_birth()
response = self.client.get(reverse('core-api:rqworker-detail', args=[worker1.name]), **self.header)
self.assertEqual(response.status_code, 200)
self.assertIn(str(worker1.name), str(response.content))
self.assertIn('birth_date', str(response.content))
self.assertIn('total_working_time', str(response.content))

View File

@@ -3,11 +3,12 @@ from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from core.models import ObjectType
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from extras.models import CustomField, CustomFieldChoiceSet, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase

View File

@@ -1,7 +1,13 @@
import uuid
from datetime import datetime, timezone
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from utilities.testing import ChangeLoggedFilterSetTests
from dcim.models import Site
from ipam.models import IPAddress
from users.models import User
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
from ..choices import *
from ..filtersets import *
from ..models import *
@@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet
ignore_fields = ('prechange_data', 'postchange_data')
@classmethod
def setUpTestData(cls):
users = (
User(username='user1'),
User(username='user2'),
User(username='user3'),
)
User.objects.bulk_create(users)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
object_changes = (
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
)
ObjectChange.objects.bulk_create(object_changes)
def test_q(self):
params = {'q': 'Site 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'user': ['user1', 'user2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_user_name(self):
params = {'user_name': ['user1', 'user2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from core.models import DataSource
from extras.choices import ObjectChangeActionChoices
from core.choices import ObjectChangeActionChoices
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED

View File

@@ -1,4 +1,4 @@
import logging
import urllib.parse
import uuid
from datetime import datetime
@@ -10,8 +10,11 @@ from django_rq.workers import get_worker
from rq.job import Job as RQ_Job, JobStatus
from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
from core.choices import ObjectChangeActionChoices
from core.models import *
from dcim.models import Site
from users.models import User
from utilities.testing import TestCase, ViewTestCases, create_tags
from ..models import *
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -99,6 +102,43 @@ class DataFileTestCase(
DataFile.objects.bulk_create(data_files)
# TODO: Convert to StandardTestCases.Views
class ObjectChangeTestCase(TestCase):
user_permissions = (
'core.view_objectchange',
)
@classmethod
def setUpTestData(cls):
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ObjectChanges
user = User.objects.create_user(username='testuser2')
for i in range(1, 4):
oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
oc.user = user
oc.request_id = uuid.uuid4()
oc.save()
def test_objectchange_list(self):
url = reverse('core:objectchange_list')
params = {
"user": User.objects.first().pk,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertHttpStatus(response, 200)
def test_objectchange(self):
objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url())
self.assertHttpStatus(response, 200)
class BackgroundTaskTestCase(TestCase):
user_permissions = ()
@@ -268,6 +308,7 @@ class BackgroundTaskTestCase(TestCase):
worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job)
worker.prepare_execution(job)
self.assertEqual(job.get_status(), JobStatus.STARTED)
@@ -305,3 +346,32 @@ class BackgroundTaskTestCase(TestCase):
self.assertIn(str(worker1.name), str(response.content))
self.assertIn('Birth', str(response.content))
self.assertIn('Total working time', str(response.content))
class SystemTestCase(TestCase):
def setUp(self):
super().setUp()
self.user.is_staff = True
self.user.save()
def test_system_view_default(self):
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)
def test_system_view_with_config_revision(self):
ConfigRevision.objects.create()
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)

View File

@@ -6,43 +6,50 @@ from . import views
app_name = 'core'
urlpatterns = (
# Data sources
path('data-sources/', views.DataSourceListView.as_view(), name='datasource_list'),
path('data-sources/add/', views.DataSourceEditView.as_view(), name='datasource_add'),
path('data-sources/import/', views.DataSourceBulkImportView.as_view(), name='datasource_import'),
path('data-sources/edit/', views.DataSourceBulkEditView.as_view(), name='datasource_bulk_edit'),
path('data-sources/delete/', views.DataSourceBulkDeleteView.as_view(), name='datasource_bulk_delete'),
path('data-sources/', include(get_model_urls('core', 'datasource', detail=False))),
path('data-sources/<int:pk>/', include(get_model_urls('core', 'datasource'))),
# Data files
path('data-files/', views.DataFileListView.as_view(), name='datafile_list'),
path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
path('data-files/', include(get_model_urls('core', 'datafile', detail=False))),
path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
# Job results
path('jobs/', views.JobListView.as_view(), name='job_list'),
path('jobs/delete/', views.JobBulkDeleteView.as_view(), name='job_bulk_delete'),
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
path('jobs/', include(get_model_urls('core', 'job', detail=False))),
path('jobs/<int:pk>/', include(get_model_urls('core', 'job'))),
path('changelog/', include(get_model_urls('core', 'objectchange', detail=False))),
path('changelog/<int:pk>/', include(get_model_urls('core', 'objectchange'))),
# Background Tasks
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),
path(
'background-queues/<int:queue_index>/<str:status>/',
views.BackgroundTaskListView.as_view(),
name='background_task_list'
),
path('background-tasks/<str:job_id>/', views.BackgroundTaskView.as_view(), name='background_task'),
path('background-tasks/<str:job_id>/delete/', views.BackgroundTaskDeleteView.as_view(), name='background_task_delete'),
path('background-tasks/<str:job_id>/requeue/', views.BackgroundTaskRequeueView.as_view(), name='background_task_requeue'),
path('background-tasks/<str:job_id>/enqueue/', views.BackgroundTaskEnqueueView.as_view(), name='background_task_enqueue'),
path(
'background-tasks/<str:job_id>/delete/',
views.BackgroundTaskDeleteView.as_view(),
name='background_task_delete'
),
path(
'background-tasks/<str:job_id>/requeue/',
views.BackgroundTaskRequeueView.as_view(),
name='background_task_requeue'
),
path(
'background-tasks/<str:job_id>/enqueue/',
views.BackgroundTaskEnqueueView.as_view(),
name='background_task_enqueue'
),
path('background-tasks/<str:job_id>/stop/', views.BackgroundTaskStopView.as_view(), name='background_task_stop'),
path('background-workers/<int:queue_index>/', views.WorkerListView.as_view(), name='worker_list'),
path('background-workers/<str:key>/', views.WorkerView.as_view(), name='worker'),
# Config revisions
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
path('config-revisions/', include(get_model_urls('core', 'configrevision', detail=False))),
path('config-revisions/<int:pk>/', include(get_model_urls('core', 'configrevision'))),
# System
path('system/', views.SystemView.as_view(), name='system'),
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
)

155
netbox/core/utils.py Normal file
View File

@@ -0,0 +1,155 @@
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
from django_rq.settings import QUEUES_MAP, QUEUES_LIST
from django_rq.utils import get_jobs, stop_jobs
from rq import requeue_job
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQ_Job, JobStatus as RQJobStatus
from rq.registry import (
DeferredJobRegistry,
FailedJobRegistry,
FinishedJobRegistry,
ScheduledJobRegistry,
StartedJobRegistry,
)
__all__ = (
'delete_rq_job',
'enqueue_rq_job',
'get_rq_jobs',
'get_rq_jobs_from_status',
'requeue_rq_job',
'stop_rq_job',
)
def get_rq_jobs():
"""
Return a list of all RQ jobs.
"""
jobs = set()
for queue in QUEUES_LIST:
queue = get_queue(queue['name'])
jobs.update(queue.get_jobs())
return list(jobs)
def get_rq_jobs_from_status(queue, status):
"""
Return the RQ jobs with the given status.
"""
jobs = []
try:
registry_cls = {
RQJobStatus.STARTED: StartedJobRegistry,
RQJobStatus.DEFERRED: DeferredJobRegistry,
RQJobStatus.FINISHED: FinishedJobRegistry,
RQJobStatus.FAILED: FailedJobRegistry,
RQJobStatus.SCHEDULED: ScheduledJobRegistry,
}[status]
except KeyError:
raise Http404
registry = registry_cls(queue.name, queue.connection)
job_ids = registry.get_job_ids()
if status != RQJobStatus.DEFERRED:
jobs = get_jobs(queue, job_ids, registry)
else:
# Deferred jobs require special handling
for job_id in job_ids:
try:
jobs.append(RQ_Job.fetch(job_id, connection=queue.connection, serializer=queue.serializer))
except NoSuchJobError:
pass
if jobs and status == RQJobStatus.SCHEDULED:
for job in jobs:
job.scheduled_at = registry.get_scheduled_time(job)
return jobs
def delete_rq_job(job_id):
"""
Delete the specified RQ job.
"""
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
# Remove job id from queue and delete the actual job
queue.connection.lrem(queue.key, 0, job.id)
job.delete()
def requeue_rq_job(job_id):
"""
Requeue the specified RQ job.
"""
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {id} not found.").format(id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
def enqueue_rq_job(job_id):
"""
Enqueue the specified RQ job.
"""
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {id} not found.").format(id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
try:
# _enqueue_job is new in RQ 1.14, this is used to enqueue
# job regardless of its dependencies
queue._enqueue_job(job)
except AttributeError:
queue.enqueue_job(job)
# Remove job from correct registry if needed
if job.get_status() == RQJobStatus.DEFERRED:
registry = DeferredJobRegistry(queue.name, queue.connection)
registry.remove(job)
elif job.get_status() == RQJobStatus.FINISHED:
registry = FinishedJobRegistry(queue.name, queue.connection)
registry.remove(job)
elif job.get_status() == RQJobStatus.SCHEDULED:
registry = ScheduledJobRegistry(queue.name, queue.connection)
registry.remove(job)
def stop_rq_job(job_id):
"""
Stop the specified RQ job.
"""
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
return stop_jobs(queue, job_id)[0]

View File

@@ -2,7 +2,6 @@ import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
@@ -15,32 +14,36 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
from django_rq.settings import QUEUES_MAP, QUEUES_LIST
from django_rq.utils import get_jobs, get_statistics, stop_jobs
from rq import requeue_job
from django_rq.utils import get_statistics
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQ_Job, JobStatus as RQJobStatus
from rq.registry import (
DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, ScheduledJobRegistry, StartedJobRegistry,
)
from rq.worker import Worker
from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable
#
# Data sources
#
@register_model_view(DataSource, 'list', path='', detail=False)
class DataSourceListView(generic.ObjectListView):
queryset = DataSource.objects.annotate(
file_count=count_related(DataFile, 'source')
@@ -51,16 +54,12 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource)
class DataSourceView(generic.ObjectView):
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -78,12 +77,20 @@ class DataSourceSyncView(BaseObjectView):
def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk)
job = datasource.enqueue_sync_job(request)
messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
# Enqueue the sync job & update the DataSource's status
job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
messages.success(
request,
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
)
return redirect(datasource.get_absolute_url())
@register_model_view(DataSource, 'add', detail=False)
@register_model_view(DataSource, 'edit')
class DataSourceEditView(generic.ObjectEditView):
queryset = DataSource.objects.all()
@@ -95,11 +102,13 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_import', detail=False)
class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm
@register_model_view(DataSource, 'bulk_edit', path='edit', detail=False)
class DataSourceBulkEditView(generic.BulkEditView):
queryset = DataSource.objects.annotate(
count_files=count_related(DataFile, 'source')
@@ -109,6 +118,7 @@ class DataSourceBulkEditView(generic.BulkEditView):
form = forms.DataSourceBulkEditForm
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate(
count_files=count_related(DataFile, 'source')
@@ -121,6 +131,7 @@ class DataSourceBulkDeleteView(generic.BulkDeleteView):
# Data files
#
@register_model_view(DataFile, 'list', path='', detail=False)
class DataFileListView(generic.ObjectListView):
queryset = DataFile.objects.defer('data')
filterset = filtersets.DataFileFilterSet
@@ -141,6 +152,7 @@ class DataFileDeleteView(generic.ObjectDeleteView):
queryset = DataFile.objects.all()
@register_model_view(DataFile, 'bulk_delete', path='delete', detail=False)
class DataFileBulkDeleteView(generic.BulkDeleteView):
queryset = DataFile.objects.defer('data')
filterset = filtersets.DataFileFilterSet
@@ -151,6 +163,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
# Jobs
#
@register_model_view(Job, 'list', path='', detail=False)
class JobListView(generic.ObjectListView):
queryset = Job.objects.all()
filterset = filtersets.JobFilterSet
@@ -162,24 +175,98 @@ class JobListView(generic.ObjectListView):
}
@register_model_view(Job)
class JobView(generic.ObjectView):
queryset = Job.objects.all()
@register_model_view(Job, 'delete')
class JobDeleteView(generic.ObjectDeleteView):
queryset = Job.objects.all()
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
class JobBulkDeleteView(generic.BulkDeleteView):
queryset = Job.objects.all()
filterset = filtersets.JobFilterSet
table = tables.JobTable
#
# Change logging
#
@register_model_view(ObjectChange, 'list', path='', detail=False)
class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html'
actions = {
'export': {'view'},
}
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id
).exclude(
pk=instance.pk
)
related_changes_table = tables.ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type,
changed_object_id=instance.changed_object_id,
)
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 not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
non_atomic_change = True
prechange_data = prev_change.postchange_data_clean
else:
non_atomic_change = False
prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prechange_data or dict(),
instance.postchange_data_clean or dict(),
exclude=['last_updated'],
)
diff_removed = {
x: prechange_data.get(x) for x in diff_added
} if prechange_data else {}
else:
diff_added = None
diff_removed = None
return {
'diff_added': diff_added,
'diff_removed': diff_removed,
'next_change': next_change,
'prev_change': prev_change,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count(),
'non_atomic_change': non_atomic_change
}
#
# Config Revisions
#
@register_model_view(ConfigRevision, 'list', path='', detail=False)
class ConfigRevisionListView(generic.ObjectListView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
@@ -192,6 +279,7 @@ class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
@register_model_view(ConfigRevision, 'add', detail=False)
class ConfigRevisionEditView(generic.ObjectEditView):
queryset = ConfigRevision.objects.all()
form = forms.ConfigRevisionForm
@@ -202,12 +290,14 @@ class ConfigRevisionDeleteView(generic.ObjectDeleteView):
queryset = ConfigRevision.objects.all()
@register_model_view(ConfigRevision, 'bulk_delete', path='delete', detail=False)
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
table = tables.ConfigRevisionTable
@register_model_view(ConfigRevision, 'restore')
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
@@ -224,7 +314,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
current_config.data.get(param.name, None) if current_config else None,
candidate_config.data.get(param.name, None)
))
@@ -239,7 +329,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
candidate_config.activate()
messages.success(request, f"Restored configuration revision #{pk}")
messages.success(request, _("Restored configuration revision #{id}").format(id=pk))
return redirect(candidate_config.get_absolute_url())
@@ -270,41 +360,12 @@ class BackgroundTaskListView(TableMixin, BaseRQView):
table = tables.BackgroundTaskTable
def get_table_data(self, request, queue, status):
jobs = []
# Call get_jobs() to returned queued tasks
if status == RQJobStatus.QUEUED:
return queue.get_jobs()
# For other statuses, determine the registry to list (or raise a 404 for invalid statuses)
try:
registry_cls = {
RQJobStatus.STARTED: StartedJobRegistry,
RQJobStatus.DEFERRED: DeferredJobRegistry,
RQJobStatus.FINISHED: FinishedJobRegistry,
RQJobStatus.FAILED: FailedJobRegistry,
RQJobStatus.SCHEDULED: ScheduledJobRegistry,
}[status]
except KeyError:
raise Http404
registry = registry_cls(queue.name, queue.connection)
job_ids = registry.get_job_ids()
if status != RQJobStatus.DEFERRED:
jobs = get_jobs(queue, job_ids, registry)
else:
# Deferred jobs require special handling
for job_id in job_ids:
try:
jobs.append(RQ_Job.fetch(job_id, connection=queue.connection, serializer=queue.serializer))
except NoSuchJobError:
pass
if jobs and status == RQJobStatus.SCHEDULED:
for job in jobs:
job.scheduled_at = registry.get_scheduled_time(job)
return jobs
return get_rq_jobs_from_status(queue, status)
def get(self, request, queue_index, status):
queue = get_queue_by_index(queue_index)
@@ -370,22 +431,10 @@ class BackgroundTaskDeleteView(BaseRQView):
form = ConfirmationForm(request.POST)
if form.is_valid():
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
# Remove job id from queue and delete the actual job
queue.connection.lrem(queue.key, 0, job.id)
job.delete()
messages.success(request, f'Deleted job {job_id}')
delete_rq_job(job_id)
messages.success(request, _('Job {id} has been deleted.').format(id=job_id))
else:
messages.error(request, f'Error deleting job: {form.errors[0]}')
messages.error(request, _('Error deleting job {id}: {error}').format(id=job_id, error=form.errors[0]))
return redirect(reverse('core:background_queue_list'))
@@ -393,18 +442,8 @@ class BackgroundTaskDeleteView(BaseRQView):
class BackgroundTaskRequeueView(BaseRQView):
def get(self, request, job_id):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
messages.success(request, f'You have successfully requeued: {job_id}')
requeue_rq_job(job_id)
messages.success(request, _('Job {id} has been re-enqueued.').format(id=job_id))
return redirect(reverse('core:background_task', args=[job_id]))
@@ -412,55 +451,19 @@ class BackgroundTaskEnqueueView(BaseRQView):
def get(self, request, job_id):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
try:
# _enqueue_job is new in RQ 1.14, this is used to enqueue
# job regardless of its dependencies
queue._enqueue_job(job)
except AttributeError:
queue.enqueue_job(job)
# Remove job from correct registry if needed
if job.get_status() == RQJobStatus.DEFERRED:
registry = DeferredJobRegistry(queue.name, queue.connection)
registry.remove(job)
elif job.get_status() == RQJobStatus.FINISHED:
registry = FinishedJobRegistry(queue.name, queue.connection)
registry.remove(job)
elif job.get_status() == RQJobStatus.SCHEDULED:
registry = ScheduledJobRegistry(queue.name, queue.connection)
registry.remove(job)
messages.success(request, f'You have successfully enqueued: {job_id}')
enqueue_rq_job(job_id)
messages.success(request, _('Job {id} has been enqueued.').format(id=job_id))
return redirect(reverse('core:background_task', args=[job_id]))
class BackgroundTaskStopView(BaseRQView):
def get(self, request, job_id):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
try:
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
except NoSuchJobError:
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
queue_index = QUEUES_MAP[job.origin]
queue = get_queue_by_index(queue_index)
stopped, _ = stop_jobs(queue, job_id)
if len(stopped) == 1:
messages.success(request, f'You have successfully stopped {job_id}')
stopped_jobs = stop_rq_job(job_id)
if len(stopped_jobs) == 1:
messages.success(request, _('Job {id} has been stopped.').format(id=job_id))
else:
messages.error(request, f'Failed to stop {job_id}')
messages.error(request, _('Failed to stop job {id}').format(id=job_id))
return redirect(reverse('core:background_task', args=[job_id]))
@@ -515,7 +518,7 @@ class WorkerView(BaseRQView):
#
# Plugins
# System
#
class SystemView(UserPassesTestMixin, View):
@@ -539,7 +542,7 @@ class SystemView(UserPassesTestMixin, View):
except (ProgrammingError, IndexError):
pass
stats = {
'netbox_version': settings.VERSION,
'netbox_release': settings.RELEASE,
'django_version': DJANGO_VERSION,
'python_version': platform.python_version(),
'postgresql_version': psql_version,
@@ -548,39 +551,93 @@ class SystemView(UserPassesTestMixin, View):
'rq_worker_count': Worker.count(get_connection('default')),
}
# Plugins
plugins = [
# Look up app config by package name
apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
]
# Configuration
try:
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = ConfigRevision(data=get_config().defaults)
config = get_config()
# Raw data export
if 'export' in request.GET:
stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS]
data = {
**stats,
'plugins': {
plugin.name: plugin.version for plugin in plugins
},
'plugins': settings.PLUGINS,
'config': {
k: config.data[k] for k in sorted(config.data)
k: getattr(config, k) for k in sorted(params)
},
}
response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
plugins_table = tables.PluginTable(plugins, orderable=False)
plugins_table.configure(request)
# Serialize any CustomValidator classes
if hasattr(config, 'CUSTOM_VALIDATORS') and config.CUSTOM_VALIDATORS:
config.CUSTOM_VALIDATORS = json.dumps(config.CUSTOM_VALIDATORS, cls=ConfigJSONEncoder, indent=4)
return render(request, 'core/system.html', {
'stats': stats,
'plugins_table': plugins_table,
'config': config,
})
#
# Plugins
#
class BasePluginView(UserPassesTestMixin, View):
CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
def test_func(self):
return self.request.user.is_staff
def get_cached_plugins(self, request):
catalog_plugins = {}
catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
if not catalog_plugins_error:
catalog_plugins = get_catalog_plugins()
if not catalog_plugins:
# Cache for 5 minutes to avoid spamming connection
cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
messages.warning(request, _("Plugins catalog could not be loaded"))
return get_local_plugins(catalog_plugins)
class PluginListView(BasePluginView):
def get(self, request):
q = request.GET.get('q', None)
plugins = self.get_cached_plugins(request).values()
if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
table = CatalogPluginTable(plugins, user=request.user)
table.configure(request)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
return render(request, 'htmx/table.html', {
'table': table,
})
return render(request, 'core/plugin_list.html', {
'table': table,
})
class PluginView(BasePluginView):
def get(self, request, name):
plugins = self.get_cached_plugins(request)
if name not in plugins:
raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name]
table = PluginVersionTable(plugin.release_recent_history, user=request.user)
table.configure(request)
return render(request, 'core/plugin.html', {
'plugin': plugin,
'table': table,
})

View File

@@ -1,501 +0,0 @@
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from dcim import models
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
'NestedConsolePortSerializer',
'NestedConsolePortTemplateSerializer',
'NestedConsoleServerPortSerializer',
'NestedConsoleServerPortTemplateSerializer',
'NestedDeviceBaySerializer',
'NestedDeviceBayTemplateSerializer',
'NestedDeviceRoleSerializer',
'NestedDeviceSerializer',
'NestedDeviceTypeSerializer',
'NestedFrontPortSerializer',
'NestedFrontPortTemplateSerializer',
'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer',
'NestedInventoryItemSerializer',
'NestedInventoryItemRoleSerializer',
'NestedInventoryItemTemplateSerializer',
'NestedManufacturerSerializer',
'NestedModuleBaySerializer',
'NestedModuleBayTemplateSerializer',
'NestedModuleSerializer',
'NestedModuleTypeSerializer',
'NestedPlatformSerializer',
'NestedPowerFeedSerializer',
'NestedPowerOutletSerializer',
'NestedPowerOutletTemplateSerializer',
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer',
'NestedLocationSerializer',
'NestedRackReservationSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
'NestedRearPortSerializer',
'NestedRearPortTemplateSerializer',
'NestedRegionSerializer',
'NestedSiteSerializer',
'NestedSiteGroupSerializer',
'NestedVirtualChassisSerializer',
'NestedVirtualDeviceContextSerializer',
]
#
# Regions/sites
#
@extend_schema_serializer(
exclude_fields=('site_count',),
)
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.Region
fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
@extend_schema_serializer(
exclude_fields=('site_count',),
)
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):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = models.Site
fields = ['id', 'url', 'display', 'name', 'slug']
#
# Racks
#
@extend_schema_serializer(
exclude_fields=('rack_count',),
)
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.Location
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth']
@extend_schema_serializer(
exclude_fields=('rack_count',),
)
class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = RelatedObjectCountField('racks')
class Meta:
model = models.RackRole
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count']
@extend_schema_serializer(
exclude_fields=('device_count',),
)
class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
device_count = RelatedObjectCountField('devices')
class Meta:
model = models.Rack
fields = ['id', 'url', 'display', 'name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
user = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.RackReservation
fields = ['id', 'url', 'display', 'user', 'units']
def get_user(self, obj):
return obj.user.username
#
# Device/module types
#
@extend_schema_serializer(
exclude_fields=('devicetype_count',),
)
class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = RelatedObjectCountField('device_types')
class Meta:
model = models.Manufacturer
fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count']
@extend_schema_serializer(
exclude_fields=('device_count',),
)
class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
device_count = RelatedObjectCountField('instances')
class Meta:
model = models.DeviceType
fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
class NestedModuleTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
class Meta:
model = models.ModuleType
fields = ['id', 'url', 'display', 'manufacturer', 'model']
#
# Component templates
#
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
class Meta:
model = models.ConsolePortTemplate
fields = ['id', 'url', 'display', 'name']
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
class Meta:
model = models.ConsoleServerPortTemplate
fields = ['id', 'url', 'display', 'name']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
class Meta:
model = models.PowerPortTemplate
fields = ['id', 'url', 'display', 'name']
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
class Meta:
model = models.PowerOutletTemplate
fields = ['id', 'url', 'display', 'name']
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
class Meta:
model = models.InterfaceTemplate
fields = ['id', 'url', 'display', 'name']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = models.RearPortTemplate
fields = ['id', 'url', 'display', 'name']
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta:
model = models.FrontPortTemplate
fields = ['id', 'url', 'display', 'name']
class NestedModuleBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
class Meta:
model = models.ModuleBayTemplate
fields = ['id', 'url', 'display', 'name']
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
class Meta:
model = models.DeviceBayTemplate
fields = ['id', 'url', 'display', 'name']
class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.InventoryItemTemplate
fields = ['id', 'url', 'display', 'name', '_depth']
#
# Devices
#
@extend_schema_serializer(
exclude_fields=('device_count', 'virtualmachine_count'),
)
class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta:
model = models.DeviceRole
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
@extend_schema_serializer(
exclude_fields=('device_count', 'virtualmachine_count'),
)
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta:
model = models.Platform
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta:
model = models.Device
fields = ['id', 'url', 'display', 'name']
class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
class Meta:
model = models.ModuleBay
fields = ['id', 'url', 'display', 'name']
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
class Meta:
model = models.Module
fields = ['id', 'url', 'display', 'serial']
class NestedModuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
device = NestedDeviceSerializer(read_only=True)
module_bay = ModuleNestedModuleBaySerializer(read_only=True)
module_type = NestedModuleTypeSerializer(read_only=True)
class Meta:
model = models.Module
fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsoleServerPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedConsolePortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsolePort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerOutlet
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.Interface
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedRearPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.RearPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedFrontPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.FrontPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedModuleBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
class Meta:
model = models.ModuleBay
fields = ['id', 'url', 'display', 'installed_module', 'name']
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = models.DeviceBay
fields = ['id', 'url', 'display', 'device', 'name']
class NestedInventoryItemSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.InventoryItem
fields = ['id', 'url', 'display', 'device', 'name', '_depth']
@extend_schema_serializer(
exclude_fields=('inventoryitem_count',),
)
class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = RelatedObjectCountField('inventory_items')
class Meta:
model = models.InventoryItemRole
fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count']
#
# Cables
#
class NestedCableSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = models.Cable
fields = ['id', 'url', 'display', 'label']
#
# Virtual chassis
#
@extend_schema_serializer(
exclude_fields=('member_count',),
)
class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = models.VirtualChassis
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
#
# Power panels/feeds
#
@extend_schema_serializer(
exclude_fields=('powerfeed_count',),
)
class NestedPowerPanelSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
powerfeed_count = RelatedObjectCountField('powerfeeds')
class Meta:
model = models.PowerPanel
fields = ['id', 'url', 'display', 'name', 'powerfeed_count']
class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerFeed
fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = NestedDeviceSerializer()
class Meta:
model = models.VirtualDeviceContext
fields = ['id', 'url', 'display', 'name', 'identifier', 'device']

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