mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-21 21:02:23 -06:00
Merge main into feature
This commit is contained in:
@@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet):
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
label=_('Provider (ID)'),
|
||||
@@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
to_field_name='slug',
|
||||
label=_('Site (slug)'),
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='terminations___location',
|
||||
label=_('Location (ID)'),
|
||||
queryset=Location.objects.all(),
|
||||
)
|
||||
termination_a_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=CircuitTermination.objects.all(),
|
||||
label=_('Termination A (ID)'),
|
||||
|
||||
@@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
|
||||
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ProviderAccount
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
@@ -126,7 +127,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
'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('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
@@ -181,6 +182,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location')
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
label=_('Install date'),
|
||||
required=False,
|
||||
@@ -322,7 +328,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet('type', 'status', name=_('Attributes')),
|
||||
FieldSet('type_id', 'status', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
||||
|
||||
@@ -49,7 +49,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
|
||||
filters=ProviderAccountFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ProviderAccountType(NetBoxObjectType):
|
||||
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
|
||||
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
|
||||
|
||||
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
|
||||
|
||||
@@ -347,9 +347,8 @@ class CircuitTermination(
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Must define either site *or* provider network
|
||||
if self.termination is None:
|
||||
raise ValidationError(_("A circuit termination must attach to termination."))
|
||||
raise ValidationError(_("A circuit termination must attach to a terminating object."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
|
||||
@@ -111,7 +111,7 @@ class CircuitTerminationTable(NetBoxTable):
|
||||
provider = tables.Column(
|
||||
verbose_name=_('Provider'),
|
||||
linkify=True,
|
||||
accessor='circuit.provider'
|
||||
accessor='circuit__provider'
|
||||
)
|
||||
term_side = tables.Column(
|
||||
verbose_name=_('Side')
|
||||
|
||||
@@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Accounts')
|
||||
)
|
||||
account_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('accounts__count'),
|
||||
viewname='circuits:provideraccount_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('Account Count')
|
||||
@@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
|
||||
@@ -3,8 +3,10 @@ from django.test import TestCase
|
||||
from circuits.choices import *
|
||||
from circuits.filtersets import *
|
||||
from circuits.models import *
|
||||
from dcim.choices import InterfaceTypeChoices
|
||||
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
|
||||
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
|
||||
)
|
||||
from ipam.models import ASN, RIR
|
||||
from netbox.choices import DistanceUnitChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
locations = (
|
||||
Location.objects.create(
|
||||
site=sites[0], name='Test Location 1', slug='test-location-1',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
),
|
||||
Location.objects.create(
|
||||
site=sites[1], name='Test Location 2', slug='test-location-2',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
),
|
||||
)
|
||||
|
||||
circuits = (
|
||||
Circuit(
|
||||
provider=providers[0],
|
||||
@@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[0], termination=locations[0], term_side='Z'),
|
||||
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[1], termination=locations[1], term_side='Z'),
|
||||
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'),
|
||||
@@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
location_ids = Location.objects.values_list('id', flat=True)[:2]
|
||||
params = {'location_id': location_ids}
|
||||
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]}
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from ipam.models import ASN
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.query import count_related
|
||||
@@ -19,7 +20,9 @@ from .models import *
|
||||
@register_model_view(Provider, 'list', path='', detail=False)
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider'),
|
||||
asn_count=count_related(ASN, 'providers'),
|
||||
account_count=count_related(ProviderAccount, 'provider'),
|
||||
)
|
||||
filterset = filtersets.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
|
||||
@@ -2,12 +2,13 @@ import re
|
||||
import typing
|
||||
from collections import OrderedDict
|
||||
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
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 drf_spectacular.utils import Direction
|
||||
|
||||
from netbox.api.fields import ChoiceField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
@@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
||||
return component.ref if component else None
|
||||
else:
|
||||
return build_basic_type(OpenApiTypes.INT)
|
||||
|
||||
|
||||
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
|
||||
target_class = 'netbox.api.fields.IntegerRangeSerializer'
|
||||
|
||||
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
|
||||
return {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'integer',
|
||||
},
|
||||
'minItems': 2,
|
||||
'maxItems': 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Nested models can be passed by ID in requests
|
||||
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
|
||||
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
|
||||
target_class = 'netbox.api.serializers.BaseModelSerializer'
|
||||
match_subclasses = True
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
|
||||
if schema is None:
|
||||
return schema
|
||||
if direction == 'request' and self.target.nested:
|
||||
return {
|
||||
'oneOf': [
|
||||
build_basic_type(OpenApiTypes.INT),
|
||||
schema,
|
||||
]
|
||||
}
|
||||
return schema
|
||||
|
||||
@@ -3,7 +3,10 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.events import *
|
||||
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
@@ -27,6 +30,15 @@ class CoreConfig(AppConfig):
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# 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()
|
||||
|
||||
# Clear Redis cache on startup in development mode
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
|
||||
@@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet):
|
||||
CHOICES = (
|
||||
(INTERVAL_MINUTELY, _('Minutely')),
|
||||
(INTERVAL_HOURLY, _('Hourly')),
|
||||
(INTERVAL_HOURLY * 12, _('12 hours')),
|
||||
(INTERVAL_DAILY, _('Daily')),
|
||||
(INTERVAL_WEEKLY, _('Weekly')),
|
||||
(INTERVAL_DAILY * 30, _('30 days')),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
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',
|
||||
@@ -22,12 +18,3 @@ 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()
|
||||
|
||||
@@ -67,6 +67,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = Job
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'status', name=_('Attributes')),
|
||||
@@ -167,6 +168,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigRevision
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import uuid
|
||||
from functools import partial
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -258,10 +259,12 @@ class Job(models.Model):
|
||||
|
||||
# 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)
|
||||
callback = partial(queue.enqueue_at, schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
# Schedule the job to run asynchronously at this first available opportunity.
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
callback = partial(queue.enqueue, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
return job
|
||||
|
||||
@@ -87,6 +87,13 @@ def get_local_plugins(plugins=None):
|
||||
if plugin_config.release_track:
|
||||
installed_version = f'{installed_version}-{plugin_config.release_track}'
|
||||
|
||||
if plugin_config.author:
|
||||
author = PluginAuthor(
|
||||
name=plugin_config.author,
|
||||
)
|
||||
else:
|
||||
author = None
|
||||
|
||||
local_plugins[plugin_config.name] = Plugin(
|
||||
config_name=plugin_config.name,
|
||||
title_short=plugin_config.verbose_name,
|
||||
@@ -98,6 +105,7 @@ def get_local_plugins(plugins=None):
|
||||
installed_version=installed_version,
|
||||
netbox_min_version=plugin_config.min_version,
|
||||
netbox_max_version=plugin_config.max_version,
|
||||
author=author,
|
||||
)
|
||||
|
||||
# Update catalog entries for local plugins, or add them to the list if not listed
|
||||
|
||||
@@ -166,7 +166,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Job, 'list', path='', detail=False)
|
||||
class JobListView(generic.ObjectListView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
@@ -183,12 +183,12 @@ class JobView(generic.ObjectView):
|
||||
|
||||
@register_model_view(Job, 'delete')
|
||||
class JobDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
|
||||
|
||||
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
|
||||
class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
table = tables.JobTable
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -448,7 +449,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'_path', 'cable__terminations',
|
||||
GenericPrefetch(
|
||||
"cable__terminations__termination",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
GenericPrefetch(
|
||||
"_path__path_objects",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
|
||||
'ip_addresses', # Referenced by Interface.count_ipaddresses()
|
||||
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()
|
||||
|
||||
@@ -989,6 +989,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
|
||||
# Coaxial
|
||||
TYPE_DOCSIS = 'docsis'
|
||||
TYPE_MOCA = 'moca'
|
||||
|
||||
# PON
|
||||
TYPE_BPON = 'bpon'
|
||||
@@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
_('Coaxial'),
|
||||
(
|
||||
(TYPE_DOCSIS, 'DOCSIS'),
|
||||
(TYPE_MOCA, 'MoCA'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@@ -1348,6 +1350,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_SC_UPC = 'sc-upc'
|
||||
TYPE_SC_APC = 'sc-apc'
|
||||
TYPE_FC = 'fc'
|
||||
TYPE_FC_PC = 'fc-pc'
|
||||
TYPE_FC_UPC = 'fc-upc'
|
||||
TYPE_FC_APC = 'fc-apc'
|
||||
TYPE_LC = 'lc'
|
||||
TYPE_LC_PC = 'lc-pc'
|
||||
TYPE_LC_UPC = 'lc-upc'
|
||||
@@ -1408,6 +1413,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
_('Fiber Optic'),
|
||||
(
|
||||
(TYPE_FC, 'FC'),
|
||||
(TYPE_FC_PC, 'FC/PC'),
|
||||
(TYPE_FC_UPC, 'FC/UPC'),
|
||||
(TYPE_FC_APC, 'FC/APC'),
|
||||
(TYPE_LC, 'LC'),
|
||||
(TYPE_LC_PC, 'LC/PC'),
|
||||
(TYPE_LC_UPC, 'LC/UPC'),
|
||||
|
||||
2
netbox/dcim/exceptions.py
Normal file
2
netbox/dcim/exceptions.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class UnsupportedCablePath(Exception):
|
||||
pass
|
||||
@@ -1246,6 +1246,7 @@ class DeviceFilterSet(
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(virtual_chassis__name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
|
||||
@@ -1458,7 +1458,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
||||
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'wireless_lans'
|
||||
'wireless_lans', 'vlan_translation_policy'
|
||||
])
|
||||
):
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -1611,7 +1611,9 @@ class InterfaceBulkEditForm(
|
||||
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
||||
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')),
|
||||
FieldSet(
|
||||
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
|
||||
),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('tagged_vlans', name=_('Assignment')),
|
||||
@@ -1626,7 +1628,7 @@ class InterfaceBulkEditForm(
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans'
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1192,27 +1192,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.none()
|
||||
|
||||
def clean_component_name(self):
|
||||
content_type = self.cleaned_data.get('component_type')
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
def clean(self):
|
||||
super().clean()
|
||||
cleaned_data = self.cleaned_data
|
||||
component_type = cleaned_data.get('component_type')
|
||||
component_name = cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
if component_type:
|
||||
if device is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if component_name is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
)
|
||||
if all([device, component_name]):
|
||||
try:
|
||||
model = component_type.model_class()
|
||||
self.instance.component = model.objects.get(device=device, name=component_name)
|
||||
except ObjectDoesNotExist:
|
||||
cleaned_data.pop('component_type', None)
|
||||
cleaned_data.pop('component_name', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if not component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
else:
|
||||
if component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component type must be specified when component name is specified")
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -304,7 +304,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
model = RackType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
|
||||
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
)
|
||||
|
||||
@@ -153,6 +153,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
@@ -302,6 +303,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
|
||||
@@ -30,6 +30,7 @@ class PathEndpointMixin:
|
||||
|
||||
connected_endpoints: List[Annotated[Union[
|
||||
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
|
||||
@@ -471,7 +471,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
|
||||
)
|
||||
class InventoryItemType(ComponentType):
|
||||
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
|
||||
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import utilities.json
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0204_device_role_rebuild'),
|
||||
('extras', '0125_exporttemplate_file_name'),
|
||||
('extras', '0126_exporttemplate_file_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -15,7 +14,8 @@ from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.fields import ColorField
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.fields import ColorField, GenericArrayForeignKey
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
@@ -26,6 +26,7 @@ __all__ = (
|
||||
'CableTermination',
|
||||
)
|
||||
|
||||
from ..exceptions import UnsupportedCablePath
|
||||
|
||||
trace_paths = Signal()
|
||||
|
||||
@@ -236,8 +237,10 @@ class Cable(PrimaryModel):
|
||||
for termination in self.b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
try:
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
except UnsupportedCablePath as e:
|
||||
raise AbortRequest(e)
|
||||
|
||||
def get_status_color(self):
|
||||
return LinkStatusChoices.colors.get(self.status)
|
||||
@@ -486,13 +489,16 @@ class CablePath(models.Model):
|
||||
return ObjectType.objects.get_for_id(ct_id)
|
||||
|
||||
@property
|
||||
def path_objects(self):
|
||||
"""
|
||||
Cache and return the complete path as lists of objects, derived from their annotation within the path.
|
||||
"""
|
||||
if not hasattr(self, '_path_objects'):
|
||||
self._path_objects = self._get_path()
|
||||
return self._path_objects
|
||||
def _path_decompiled(self):
|
||||
res = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
nodes.append(decompile_path_node(node))
|
||||
res.append(nodes)
|
||||
return res
|
||||
|
||||
path_objects = GenericArrayForeignKey("_path_decompiled")
|
||||
|
||||
@property
|
||||
def origins(self):
|
||||
@@ -527,8 +533,8 @@ class CablePath(models.Model):
|
||||
return None
|
||||
|
||||
# Ensure all originating terminations are attached to the same link
|
||||
if len(terminations) > 1:
|
||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
||||
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
|
||||
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
|
||||
|
||||
path = []
|
||||
position_stack = []
|
||||
@@ -539,12 +545,13 @@ class CablePath(models.Model):
|
||||
while terminations:
|
||||
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
|
||||
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
if (not isinstance(terminations[0], PathEndpoint) and not
|
||||
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
|
||||
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
@@ -567,8 +574,10 @@ class CablePath(models.Model):
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
if not all(type(link) in (Cable, WirelessLink) for link in links):
|
||||
raise UnsupportedCablePath(_("All links must be cable or wireless"))
|
||||
if not all(isinstance(link, type(links[0])) for link in links):
|
||||
raise UnsupportedCablePath(_("All links must match first link type"))
|
||||
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
@@ -649,14 +658,18 @@ class CablePath(models.Model):
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
if len(remote_terminations) != len(positions):
|
||||
raise UnsupportedCablePath(
|
||||
_("All positions counts within the path on opposite ends of links must match")
|
||||
)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
if q_filter is Q():
|
||||
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
@@ -742,42 +755,6 @@ class CablePath(models.Model):
|
||||
self.delete()
|
||||
retrace.alters_data = True
|
||||
|
||||
def _get_path(self):
|
||||
"""
|
||||
Return the path as a list of prefetched objects.
|
||||
"""
|
||||
# Compile a list of IDs to prefetch for each type of model in the path
|
||||
to_prefetch = defaultdict(list)
|
||||
for node in self._nodes:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
to_prefetch[ct_id].append(object_id)
|
||||
|
||||
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
|
||||
prefetched = {}
|
||||
for ct_id, object_ids in to_prefetch.items():
|
||||
model_class = ObjectType.objects.get_for_id(ct_id).model_class()
|
||||
queryset = model_class.objects.filter(pk__in=object_ids)
|
||||
if hasattr(model_class, 'device'):
|
||||
queryset = queryset.prefetch_related('device')
|
||||
prefetched[ct_id] = {
|
||||
obj.id: obj for obj in queryset
|
||||
}
|
||||
|
||||
# Replicate the path using the prefetched objects.
|
||||
path = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
try:
|
||||
nodes.append(prefetched[ct_id][object_id])
|
||||
except KeyError:
|
||||
# Ignore stale (deleted) object IDs
|
||||
pass
|
||||
path.append(nodes)
|
||||
|
||||
return path
|
||||
|
||||
def get_cable_ids(self):
|
||||
"""
|
||||
Return all Cable IDs within the path.
|
||||
|
||||
@@ -184,8 +184,11 @@ class CabledObjectModel(models.Model):
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
|
||||
return [peer.termination for peer in peers]
|
||||
return [
|
||||
peer.termination
|
||||
for peer in self.cable.terminations.all()
|
||||
if peer.cable_end != self.cable_end
|
||||
]
|
||||
return []
|
||||
|
||||
@property
|
||||
|
||||
@@ -690,14 +690,10 @@ class Device(
|
||||
verbose_name_plural = _('devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name and self.asset_tag:
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.asset_tag:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
if self.label and self.asset_tag:
|
||||
return f'{self.label} ({self.asset_tag})'
|
||||
elif self.label:
|
||||
return self.label
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
@@ -961,14 +957,22 @@ class Device(
|
||||
device.location = self.location
|
||||
device.save()
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
"""
|
||||
Return the device name if set; otherwise return a generated name if available.
|
||||
"""
|
||||
if self.name:
|
||||
return self.name
|
||||
if self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position}'
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Return the device name if set; otherwise return the Device's primary key as {pk}
|
||||
"""
|
||||
if self.name is not None:
|
||||
return self.name
|
||||
return '{{{}}}'.format(self.pk)
|
||||
return self.label or '{{{}}}'.format(self.pk)
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
@@ -1268,7 +1272,10 @@ class MACAddress(PrimaryModel):
|
||||
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
|
||||
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
|
||||
|
||||
if original_assigned_object.primary_mac_address:
|
||||
if (
|
||||
original_assigned_object.primary_mac_address
|
||||
and original_assigned_object.primary_mac_address.pk == self.pk
|
||||
):
|
||||
if not assigned_object:
|
||||
raise ValidationError(
|
||||
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object")
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
from dcim.utils import update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -341,6 +342,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
|
||||
@@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('virtual_chassis', 200),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
@@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
name = device.name
|
||||
if device.label:
|
||||
name = device.label
|
||||
else:
|
||||
name = str(device.device_type)
|
||||
if device.devicebay_count:
|
||||
|
||||
@@ -144,7 +144,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
verbose_name=_('Name'),
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
linkify=True,
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
@@ -678,7 +678,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
racktype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:racktype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name=_('Rack Types')
|
||||
)
|
||||
devicetype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicetype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
@@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug',
|
||||
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
@@ -153,6 +152,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name=_('Devices')
|
||||
)
|
||||
vlangroup_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:vlangroup_list',
|
||||
url_params={'location': 'pk'},
|
||||
verbose_name=_('VLAN Groups')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
@@ -168,7 +172,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'vlangroup_count',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
|
||||
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
|
||||
'description'
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ WEIGHT = """
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
{{ value|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
{{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
@@ -164,8 +164,8 @@ CONSOLEPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -177,7 +177,7 @@ CONSOLEPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -214,8 +214,8 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -227,7 +227,7 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -264,8 +264,8 @@ POWERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -276,7 +276,7 @@ POWERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -313,14 +313,14 @@ POWEROUTLET_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
@@ -407,8 +407,8 @@ INTERFACE_BUTTONS = """
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.is_wired and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
|
||||
@@ -422,7 +422,7 @@ INTERFACE_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
|
||||
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
||||
@@ -464,8 +464,8 @@ FRONTPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -481,7 +481,7 @@ FRONTPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
@@ -519,8 +519,8 @@ REARPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -536,7 +536,7 @@ REARPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
@@ -1979,6 +1979,27 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_front_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
front_port = FrontPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rear_port,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
@@ -2022,6 +2043,23 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_rear_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from dcim.utils import object_to_path_node
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
|
||||
class CablePathTestCase(TestCase):
|
||||
@@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
@@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import (
|
||||
DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices
|
||||
DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
|
||||
PowerOutletStatusChoices,
|
||||
)
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
@@ -170,6 +171,51 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.device = create_test_device('Panel Device 1')
|
||||
cls.rear_ports = (
|
||||
RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
)
|
||||
RearPort.objects.bulk_create(cls.rear_ports)
|
||||
|
||||
def test_front_port_label_count_valid(self):
|
||||
"""
|
||||
Test that generating an equal number of names and labels passes form validation.
|
||||
"""
|
||||
front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-4]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(front_port_data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_front_port_label_count_mismatch(self):
|
||||
"""
|
||||
Check that attempting to generate a differing number of names and labels results in a validation error.
|
||||
"""
|
||||
bad_front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-2]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(bad_front_port_data)
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label', form.errors)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from core.models import ObjectType
|
||||
@@ -12,6 +12,43 @@ from utilities.data import drange
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
|
||||
class MACAddressTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
device = Device.objects.create(
|
||||
name='Device 1', device_type=device_type, role=device_role, site=site,
|
||||
)
|
||||
cls.interface = Interface.objects.create(
|
||||
device=device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
)
|
||||
|
||||
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
|
||||
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
|
||||
|
||||
cls.interface.primary_mac_address = cls.mac_a
|
||||
cls.interface.save()
|
||||
|
||||
@tag('regression')
|
||||
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
|
||||
self.mac_a.assigned_object = None
|
||||
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
|
||||
self.mac_a.clean()
|
||||
|
||||
@tag('regression')
|
||||
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
|
||||
self.mac_b.assigned_object = None
|
||||
self.mac_b.clean()
|
||||
|
||||
|
||||
class LocationTestCase(TestCase):
|
||||
|
||||
def test_change_location_site(self):
|
||||
@@ -592,6 +629,32 @@ class DeviceTestCase(TestCase):
|
||||
device2.full_clean()
|
||||
device2.save()
|
||||
|
||||
def test_device_label(self):
|
||||
device1 = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name=None,
|
||||
)
|
||||
self.assertEqual(device1.label, None)
|
||||
|
||||
device1.name = 'Test Device 1'
|
||||
self.assertEqual(device1.label, 'Test Device 1')
|
||||
|
||||
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
|
||||
device2 = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name=None,
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=2,
|
||||
)
|
||||
self.assertEqual(device2.label, 'VC 1:2')
|
||||
|
||||
device2.name = 'Test Device 2'
|
||||
self.assertEqual(device2.label, 'Test Device 2')
|
||||
|
||||
def test_device_mismatched_site_cluster(self):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
||||
@@ -10,10 +10,9 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
@@ -412,7 +411,8 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Site, 'list', path='', detail=False)
|
||||
class SiteListView(generic.ObjectListView):
|
||||
queryset = Site.objects.annotate(
|
||||
device_count=count_related(Device, 'site')
|
||||
device_count=count_related(Device, 'site'),
|
||||
asn_count=count_related(ASN, 'sites')
|
||||
)
|
||||
filterset = filtersets.SiteFilterSet
|
||||
filterset_form = forms.SiteFilterForm
|
||||
@@ -489,18 +489,24 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Location, 'list', path='', detail=False)
|
||||
class LocationListView(generic.ObjectListView):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
)
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
),
|
||||
VLANGroup,
|
||||
'location',
|
||||
'vlangroup_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.LocationFilterSet
|
||||
filterset_form = forms.LocationFilterForm
|
||||
table = tables.LocationTable
|
||||
@@ -512,6 +518,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
locations = instance.get_descendants(include_self=True)
|
||||
location_content_type = ContentType.objects.get_for_model(instance)
|
||||
return {
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
@@ -529,6 +536,8 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type_id=location_content_type.id, scope_id=instance.id), 'location'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -939,6 +948,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Manufacturer, 'list', path='', detail=False)
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
racktype_count=count_related(RackType, 'manufacturer'),
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
@@ -2049,7 +2059,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Device, 'list', path='', detail=False)
|
||||
class DeviceListView(generic.ObjectListView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related('virtual_chassis')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
@@ -2277,51 +2287,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
|
||||
|
||||
@register_model_view(Device, 'render-config')
|
||||
class DeviceRenderConfigView(generic.ObjectView):
|
||||
class DeviceRenderConfigView(ObjectRenderConfigView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/render_config.html'
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
weight=2100
|
||||
weight=2100,
|
||||
)
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
instance = self.get_object(**kwargs)
|
||||
context = self.get_extra_context(request, instance)
|
||||
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
response = context['config_template'].render_to_response(context=context['context_data'])
|
||||
return response
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'tab': self.tab,
|
||||
**context,
|
||||
})
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Compile context data
|
||||
context_data = instance.get_config_context()
|
||||
context_data.update({'device': instance})
|
||||
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = None
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
|
||||
return {
|
||||
'config_template': config_template,
|
||||
'context_data': context_data,
|
||||
'rendered_config': rendered_config,
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Device, 'virtual-machines')
|
||||
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||
|
||||
@@ -155,7 +155,6 @@ class JournalEntryKindChoices(ChoiceSet):
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
|
||||
LOG_DEBUG = 'debug'
|
||||
LOG_DEFAULT = 'default'
|
||||
LOG_INFO = 'info'
|
||||
LOG_SUCCESS = 'success'
|
||||
LOG_WARNING = 'warning'
|
||||
@@ -163,16 +162,15 @@ class LogLevelChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOG_DEBUG, _('Debug'), 'teal'),
|
||||
(LOG_DEFAULT, _('Default'), 'gray'),
|
||||
(LOG_INFO, _('Info'), 'cyan'),
|
||||
(LOG_SUCCESS, _('Success'), 'green'),
|
||||
(LOG_WARNING, _('Warning'), 'yellow'),
|
||||
(LOG_FAILURE, _('Failure'), 'red'),
|
||||
|
||||
)
|
||||
|
||||
SYSTEM_LEVELS = {
|
||||
LOG_DEBUG: logging.DEBUG,
|
||||
LOG_DEFAULT: logging.INFO,
|
||||
LOG_INFO: logging.INFO,
|
||||
LOG_SUCCESS: logging.INFO,
|
||||
LOG_WARNING: logging.WARNING,
|
||||
@@ -180,17 +178,6 @@ class LogLevelChoices(ChoiceSet):
|
||||
}
|
||||
|
||||
|
||||
class DurationChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(60, _('Hourly')),
|
||||
(720, _('12 hours')),
|
||||
(1440, _('Daily')),
|
||||
(10080, _('Weekly')),
|
||||
(43200, _('30 days')),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
@@ -141,9 +141,8 @@ DEFAULT_DASHBOARD = [
|
||||
|
||||
LOG_LEVEL_RANK = {
|
||||
LogLevelChoices.LOG_DEBUG: 0,
|
||||
LogLevelChoices.LOG_DEFAULT: 1,
|
||||
LogLevelChoices.LOG_INFO: 2,
|
||||
LogLevelChoices.LOG_SUCCESS: 3,
|
||||
LogLevelChoices.LOG_WARNING: 4,
|
||||
LogLevelChoices.LOG_FAILURE: 5,
|
||||
LogLevelChoices.LOG_INFO: 1,
|
||||
LogLevelChoices.LOG_SUCCESS: 2,
|
||||
LogLevelChoices.LOG_WARNING: 3,
|
||||
LogLevelChoices.LOG_FAILURE: 4,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import requests
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -43,6 +44,27 @@ def get_object_type_choices():
|
||||
]
|
||||
|
||||
|
||||
def object_list_widget_supports_model(model: Model) -> bool:
|
||||
"""Test whether a model is supported by the ObjectListWidget
|
||||
|
||||
In theory there could be more than one reason why a model isn't supported by the
|
||||
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
|
||||
for the model. Add more tests if more conditions arise.
|
||||
"""
|
||||
def can_resolve_model_list_view(model: Model) -> bool:
|
||||
try:
|
||||
reverse(get_viewname(model, action='list'))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
tests = [
|
||||
can_resolve_model_list_view,
|
||||
]
|
||||
|
||||
return all(test(model) for test in tests)
|
||||
|
||||
|
||||
def get_bookmarks_object_type_choices():
|
||||
return [
|
||||
(object_type_identifier(ot), object_type_name(ot))
|
||||
@@ -235,6 +257,17 @@ class ObjectListWidget(DashboardWidget):
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def clean_model(self):
|
||||
if model_info := self.cleaned_data['model']:
|
||||
app_label, model_name = model_info.split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
if not object_list_widget_supports_model(model):
|
||||
raise forms.ValidationError(
|
||||
_(f"Invalid model selection: {self['model'].data} is not supported.")
|
||||
)
|
||||
|
||||
return model_info
|
||||
|
||||
def render(self, request):
|
||||
app_label, model_name = self.config['model'].split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
@@ -258,7 +291,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
parameters['per_page'] = page_size
|
||||
parameters['embedded'] = True
|
||||
|
||||
if parameters:
|
||||
if parameters and htmx_url is not None:
|
||||
try:
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
|
||||
except ValueError:
|
||||
@@ -285,7 +318,8 @@ class RSSFeedWidget(DashboardWidget):
|
||||
|
||||
class ConfigForm(WidgetConfigForm):
|
||||
feed_url = forms.URLField(
|
||||
label=_('Feed URL')
|
||||
label=_('Feed URL'),
|
||||
assume_scheme='https'
|
||||
)
|
||||
requires_internet = forms.BooleanField(
|
||||
label=_('Requires external connection'),
|
||||
|
||||
@@ -38,6 +38,7 @@ __all__ = (
|
||||
|
||||
|
||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomField
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet(
|
||||
@@ -116,6 +117,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomFieldChoiceSet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('base_choices', 'choice', name=_('Choices')),
|
||||
@@ -130,6 +132,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomLink
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
||||
@@ -160,6 +163,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ExportTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'object_type_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -205,6 +209,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ImageAttachment
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'name', name=_('Attributes')),
|
||||
@@ -221,6 +226,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = SavedFilter
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
@@ -349,6 +355,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -438,6 +445,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -524,6 +532,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = NotificationGroup
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -15,7 +15,7 @@ from netbox.events import get_event_type_choices
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
@@ -165,6 +165,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
@@ -181,12 +182,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
# Escape colons in extra_choices
|
||||
# TODO: The check for str / list below is to handle difference in extra_choices field definition
|
||||
# In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
|
||||
# if standardize these, we can simplify this code
|
||||
|
||||
# Convert extra_choices Array Field from model to CharField for form
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
choices = []
|
||||
for choice in self.initial['extra_choices']:
|
||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||
choices.append(choice)
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
choices = ""
|
||||
for choice in extra_choices:
|
||||
# Setup choices in Add Another use case
|
||||
if isinstance(choice, str):
|
||||
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||
choices += choice_str + "\n"
|
||||
# Setup choices in Edit use case
|
||||
elif isinstance(choice, list):
|
||||
choice_str = ":".join(choice)
|
||||
choices += choice_str + "\n"
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
@@ -743,8 +757,7 @@ class ImageAttachmentForm(forms.ModelForm):
|
||||
class JournalEntryForm(NetBoxModelForm):
|
||||
kind = forms.ChoiceField(
|
||||
label=_('Kind'),
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False
|
||||
choices=JournalEntryKindChoices
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import DurationChoices
|
||||
from core.choices import JobIntervalChoices
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.datetime import local_now
|
||||
|
||||
@@ -22,7 +22,7 @@ class ReportForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
@@ -5,11 +5,11 @@ from django.conf import settings
|
||||
from django.core.files.storage import storages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import JobIntervalChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from extras.choices import DurationChoices
|
||||
from extras.storage import ScriptFileSystemStorage
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.datetime import local_now
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
'ScriptFileForm',
|
||||
@@ -35,7 +35,7 @@ class ScriptForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
||||
@@ -100,7 +100,10 @@ class ScriptJob(JobRunner):
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
if commit:
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@@ -5,7 +5,6 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
@@ -54,7 +53,7 @@ class Command(BaseCommand):
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
|
||||
@@ -81,12 +81,17 @@ class Command(BaseCommand):
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
raise CommandError()
|
||||
|
||||
# Remove extra fields from ScriptForm before passng data to script
|
||||
form.cleaned_data.pop('_schedule_at')
|
||||
form.cleaned_data.pop('_interval')
|
||||
form.cleaned_data.pop('_commit')
|
||||
|
||||
# Execute the script.
|
||||
job = ScriptJob.enqueue(
|
||||
instance=script_obj,
|
||||
user=user,
|
||||
immediate=True,
|
||||
data=data,
|
||||
data=form.cleaned_data,
|
||||
request=NetBoxFakeRequest({
|
||||
'META': {},
|
||||
'POST': data,
|
||||
|
||||
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
from extras.choices import JournalEntryKindChoices
|
||||
|
||||
|
||||
def set_kind_default(apps, schema_editor):
|
||||
"""
|
||||
Set kind to "info" on any entries with no kind assigned.
|
||||
"""
|
||||
JournalEntry = apps.get_model('extras', 'JournalEntry')
|
||||
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=set_kind_default,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.db import migrations
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
('extras', '0123_journalentry_kind_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0123_remove_staging'),
|
||||
('extras', '0124_remove_staging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0124_alter_tag_options_tag_weight'),
|
||||
('extras', '0125_alter_tag_options_tag_weight'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0125_exporttemplate_file_name'),
|
||||
('extras', '0126_exporttemplate_file_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0014_remove_redundant_indexes'),
|
||||
('extras', '0126_configtemplate_as_attachment_and_more'),
|
||||
('extras', '0127_configtemplate_as_attachment_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@@ -197,7 +197,7 @@ class ConfigContextModel(models.Model):
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||
if self.local_context_data is not None and type(self.local_context_data) is not dict:
|
||||
raise ValidationError(
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Value
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -281,12 +283,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
b) the assignment of an existing CustomField to new object types.
|
||||
"""
|
||||
if self.default is None:
|
||||
# We have to convert None to a JSON null for jsonb_set()
|
||||
value = RawSQL("'null'::jsonb", [])
|
||||
else:
|
||||
value = Value(self.default, models.JSONField())
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
|
||||
for instance in instances:
|
||||
instance.custom_field_data[self.name] = self.default
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data'),
|
||||
Value([self.name]),
|
||||
value,
|
||||
function='jsonb_set'
|
||||
)
|
||||
)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
@@ -295,22 +305,27 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
"""
|
||||
for ct in content_types:
|
||||
if model := ct.model_class():
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
model.objects.update(
|
||||
custom_field_data=F('custom_field_data') - self.name
|
||||
)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
Called when a CustomField has been renamed. Removes the original key and inserts the new
|
||||
one, copying the value of the old key.
|
||||
"""
|
||||
for ct in self.object_types.all():
|
||||
model = ct.model_class()
|
||||
params = {f'custom_field_data__{old_name}__isnull': False}
|
||||
instances = model.objects.filter(**params)
|
||||
for instance in instances:
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data') - old_name,
|
||||
Value([new_name]),
|
||||
Func(
|
||||
F('custom_field_data'),
|
||||
function='jsonb_extract_path_text',
|
||||
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
|
||||
),
|
||||
function='jsonb_set')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -515,7 +530,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(required=required, initial=initial)
|
||||
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
@@ -532,6 +547,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
@@ -546,6 +562,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
|
||||
@@ -117,6 +117,15 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
def __str__(self):
|
||||
return self.python_name
|
||||
|
||||
@property
|
||||
def ordered_scripts(self):
|
||||
script_objects = {s.name: s for s in self.scripts.all()}
|
||||
ordered = [
|
||||
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
|
||||
]
|
||||
ordered.extend(script_objects.items())
|
||||
return ordered
|
||||
|
||||
@property
|
||||
def module_scripts(self):
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class Report(BaseScript):
|
||||
|
||||
# There is no generic log() equivalent on BaseScript
|
||||
def log(self, message):
|
||||
self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
|
||||
self._log(message, None, level=LogLevelChoices.LOG_INFO)
|
||||
|
||||
def log_success(self, obj=None, message=None):
|
||||
super().log_success(message, obj)
|
||||
|
||||
@@ -213,10 +213,12 @@ class ObjectVar(ScriptVariable):
|
||||
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
|
||||
elements within the dropdown menu (optional)
|
||||
:param null_option: The label to use as a "null" selection option (optional)
|
||||
:param selector: Include an advanced object selection widget to assist the user in identifying the desired
|
||||
object (optional)
|
||||
"""
|
||||
form_field = DynamicModelChoiceField
|
||||
|
||||
def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
|
||||
def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.field_attrs.update({
|
||||
@@ -224,6 +226,7 @@ class ObjectVar(ScriptVariable):
|
||||
'query_params': query_params,
|
||||
'context': context,
|
||||
'null_option': null_option,
|
||||
'selector': selector,
|
||||
})
|
||||
|
||||
|
||||
@@ -499,7 +502,7 @@ class BaseScript:
|
||||
# Logging
|
||||
#
|
||||
|
||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
|
||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
|
||||
"""
|
||||
Log a message. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
|
||||
@@ -539,13 +539,16 @@ class ConfigContextTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Synced')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configcontext_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
||||
48
netbox/extras/tests/test_dashboard.py
Normal file
48
netbox/extras/tests/test_dashboard.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from extras.dashboard.widgets import ObjectListWidget
|
||||
|
||||
|
||||
class ObjectListWidgetTests(TestCase):
|
||||
def test_widget_config_form_validates_model(self):
|
||||
model_info = 'extras.notification'
|
||||
form = ObjectListWidget.ConfigForm({'model': model_info})
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
@tag('regression')
|
||||
def test_widget_fails_gracefully(self):
|
||||
"""
|
||||
Example:
|
||||
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
|
||||
'class': 'extras.ObjectListWidget',
|
||||
'color': 'indigo',
|
||||
'title': 'Object List',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None
|
||||
}
|
||||
}
|
||||
"""
|
||||
config = {
|
||||
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
|
||||
'color': 'yellow',
|
||||
'title': 'this should fail',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None,
|
||||
},
|
||||
}
|
||||
|
||||
class Request:
|
||||
class User:
|
||||
def has_perm(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
user = User()
|
||||
|
||||
mock_request = Request()
|
||||
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
|
||||
rendered = widget.render(mock_request)
|
||||
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
|
||||
@@ -1197,6 +1197,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'devicerole',
|
||||
'devicetype',
|
||||
'dummymodel', # From dummy_plugin
|
||||
'dummynetboxmodel', # From dummy_plugin
|
||||
'eventrule',
|
||||
'fhrpgroup',
|
||||
'frontport',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.forms import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import ObjectType
|
||||
@@ -512,3 +513,30 @@ class ConfigContextTest(TestCase):
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_valid_local_context_data(self):
|
||||
device = Device.objects.first()
|
||||
device.local_context_data = None
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = {"foo": "bar"}
|
||||
device.clean()
|
||||
|
||||
def test_invalid_local_context_data(self):
|
||||
device = Device.objects.first()
|
||||
|
||||
device.local_context_data = ""
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = 0
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = False
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = 'foo'
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
@@ -78,8 +78,11 @@ urlpatterns = [
|
||||
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
||||
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<str:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
path('scripts/<str:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
||||
|
||||
# Markdown
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import Job
|
||||
@@ -927,6 +928,61 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
class ObjectRenderConfigView(generic.ObjectView):
|
||||
base_template = None
|
||||
template_name = 'extras/object_render_config.html'
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
instance = self.get_object(**kwargs)
|
||||
context = self.get_extra_context(request, instance)
|
||||
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
content = context['rendered_config'] or context['error_message']
|
||||
response = HttpResponse(content, content_type='text')
|
||||
filename = f"{instance.name or 'config'}.txt"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.get_template_name(),
|
||||
{
|
||||
'object': instance,
|
||||
'tab': self.tab,
|
||||
**context,
|
||||
},
|
||||
)
|
||||
|
||||
def get_extra_context_data(self, request, instance):
|
||||
return {
|
||||
f'{instance._meta.model_name}': instance,
|
||||
}
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Compile context data
|
||||
context_data = instance.get_config_context()
|
||||
context_data.update(self.get_extra_context_data(request, instance))
|
||||
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = None
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
|
||||
return {
|
||||
'base_template': self.base_template,
|
||||
'config_template': config_template,
|
||||
'context_data': context_data,
|
||||
'rendered_config': rendered_config,
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Image attachments
|
||||
#
|
||||
@@ -1084,8 +1140,8 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
|
||||
if not request.htmx:
|
||||
return redirect('home')
|
||||
|
||||
initial = request.GET or {
|
||||
'widget_class': 'extras.NoteWidget',
|
||||
initial = {
|
||||
'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget',
|
||||
}
|
||||
widget_form = DashboardWidgetAddForm(initial=initial)
|
||||
widget_name = get_field_value(widget_form, 'widget_class')
|
||||
@@ -1237,6 +1293,14 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
class BaseScriptView(generic.ObjectView):
|
||||
queryset = Script.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if pk := kwargs.get('pk', False):
|
||||
return get_object_or_404(self.queryset, pk=pk)
|
||||
elif (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
|
||||
return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
def _get_script_class(self, script):
|
||||
"""
|
||||
Return an instance of the Script's Python class
|
||||
@@ -1357,9 +1421,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
index = 0
|
||||
|
||||
try:
|
||||
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)]
|
||||
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)]
|
||||
except KeyError:
|
||||
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_DEBUG]
|
||||
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
|
||||
if job.data:
|
||||
|
||||
if 'log' in job.data:
|
||||
@@ -1367,7 +1431,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
tests = job.data['tests']
|
||||
|
||||
for log in job.data['log']:
|
||||
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
|
||||
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO)
|
||||
if log_level >= log_threshold:
|
||||
index += 1
|
||||
result = {
|
||||
@@ -1390,7 +1454,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
for method, test_data in tests.items():
|
||||
if 'log' in test_data:
|
||||
for time, status, obj, url, message in test_data['log']:
|
||||
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
|
||||
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO)
|
||||
if log_level >= log_threshold:
|
||||
index += 1
|
||||
result = {
|
||||
@@ -1416,9 +1480,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
if job.completed:
|
||||
table = self.get_table(job, request, bulk_actions=False)
|
||||
|
||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)
|
||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
||||
if log_threshold not in LOG_LEVEL_RANK:
|
||||
log_threshold = LogLevelChoices.LOG_DEBUG
|
||||
log_threshold = LogLevelChoices.LOG_INFO
|
||||
|
||||
context = {
|
||||
'script': job.object,
|
||||
|
||||
@@ -12,7 +12,8 @@ from netaddr.core import AddrFormatError
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
@@ -148,7 +149,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
||||
|
||||
|
||||
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='family'
|
||||
@@ -231,6 +232,19 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('RIR (slug)'),
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='sites__group',
|
||||
lookup_expr='in',
|
||||
label=_('Site group (ID)'),
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='sites__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='sites',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -276,7 +290,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description', 'weight')
|
||||
|
||||
|
||||
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='family'
|
||||
@@ -430,7 +444,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='start_address',
|
||||
lookup_expr='family'
|
||||
@@ -522,7 +536,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
return queryset.filter(q)
|
||||
|
||||
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='family'
|
||||
@@ -1136,7 +1150,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device (ID)'),
|
||||
|
||||
@@ -177,6 +177,13 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_("VLAN's group (if any)")
|
||||
)
|
||||
vlan_site = CSVModelChoiceField(
|
||||
label=_('VLAN Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_("VLAN's site (if any)")
|
||||
)
|
||||
vlan = CSVModelChoiceField(
|
||||
label=_('VLAN'),
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -200,8 +207,8 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = (
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
|
||||
'mark_utilized', 'description', 'comments', 'tags',
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
|
||||
'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
|
||||
)
|
||||
labels = {
|
||||
'scope_id': _('Scope ID'),
|
||||
@@ -213,19 +220,19 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
if not data:
|
||||
return
|
||||
|
||||
site = data.get('site')
|
||||
vlan_site = data.get('vlan_site')
|
||||
vlan_group = data.get('vlan_group')
|
||||
|
||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||
query = Q()
|
||||
|
||||
if site:
|
||||
if vlan_site:
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}": site
|
||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}__isnull": True
|
||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
@@ -320,6 +327,13 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned interface')
|
||||
)
|
||||
fhrp_group = CSVModelChoiceField(
|
||||
label=_('FHRP Group'),
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned FHRP Group name')
|
||||
)
|
||||
is_primary = forms.BooleanField(
|
||||
label=_('Is primary'),
|
||||
help_text=_('Make this the primary IP for the assigned device'),
|
||||
@@ -334,8 +348,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
||||
'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
|
||||
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -391,6 +405,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
# Set interface assignment
|
||||
if self.cleaned_data.get('interface'):
|
||||
self.instance.assigned_object = self.cleaned_data['interface']
|
||||
if self.cleaned_data.get('fhrp_group'):
|
||||
self.instance.assigned_object = self.cleaned_data['fhrp_group']
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
@@ -94,12 +94,13 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Aggregate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'rir_id', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -141,7 +142,7 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ASN
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('rir_id', 'site_id', name=_('Assignment')),
|
||||
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
@@ -149,6 +150,11 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('RIR')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
@@ -162,7 +168,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm, ):
|
||||
model = Prefix
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
@@ -174,6 +180,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
widget=forms.HiddenInput()
|
||||
@@ -262,12 +269,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -308,7 +316,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
@@ -319,6 +327,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
parent = forms.CharField(
|
||||
@@ -421,7 +430,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
|
||||
class VLANGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
|
||||
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
|
||||
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
|
||||
FieldSet('contains_vid', name=_('VLANs')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
@@ -432,7 +441,7 @@ class VLANGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
sitegroup = DynamicModelMultipleChoiceField(
|
||||
site_group = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
@@ -598,12 +607,13 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('protocol', 'port', name=_('Attributes')),
|
||||
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
|
||||
@@ -212,7 +212,7 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_at_site': '$site',
|
||||
'available_at_site': '$scope',
|
||||
},
|
||||
label=_('VLAN'),
|
||||
)
|
||||
@@ -240,6 +240,14 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# #18605: only filter VLAN select list if scope field is a Site
|
||||
if scope_field := self.fields.get('scope', None):
|
||||
if scope_field.queryset.model is not Site:
|
||||
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
|
||||
|
||||
|
||||
class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
|
||||
@@ -5,6 +5,7 @@ import strawberry_django
|
||||
|
||||
from circuits.graphql.types import ProviderType
|
||||
from dcim.graphql.types import SiteType
|
||||
from extras.graphql.mixins import ContactsMixin
|
||||
from ipam import models
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
|
||||
@@ -101,7 +102,7 @@ class ASNRangeType(NetBoxObjectType):
|
||||
filters=AggregateFilter,
|
||||
pagination=True
|
||||
)
|
||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
prefix: str
|
||||
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -141,7 +142,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
filters=IPAddressFilter,
|
||||
pagination=True
|
||||
)
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
address: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -166,7 +167,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
filters=IPRangeFilter,
|
||||
pagination=True
|
||||
)
|
||||
class IPRangeType(NetBoxObjectType):
|
||||
class IPRangeType(NetBoxObjectType, ContactsMixin):
|
||||
start_address: str
|
||||
end_address: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
@@ -180,7 +181,7 @@ class IPRangeType(NetBoxObjectType):
|
||||
filters=PrefixFilter,
|
||||
pagination=True
|
||||
)
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
prefix: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -244,7 +245,7 @@ class RouteTargetType(NetBoxObjectType):
|
||||
filters=ServiceFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ServiceType(NetBoxObjectType):
|
||||
class ServiceType(NetBoxObjectType, ContactsMixin):
|
||||
ports: List[int]
|
||||
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
|
||||
@@ -133,10 +133,18 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
site_groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for site_group in site_groups:
|
||||
site_group.save()
|
||||
|
||||
sites = [
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3')
|
||||
Site(name='Site 1', slug='site-1', group=site_groups[0]),
|
||||
Site(name='Site 2', slug='site-2', group=site_groups[1]),
|
||||
Site(name='Site 3', slug='site-3', group=site_groups[2]),
|
||||
]
|
||||
Site.objects.bulk_create(sites)
|
||||
asns[0].sites.set([sites[0]])
|
||||
@@ -178,6 +186,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'rir': [rirs[0].slug, rirs[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site_group(self):
|
||||
site_groups = SiteGroup.objects.all()[:2]
|
||||
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
|
||||
43
netbox/ipam/tests/test_forms.py
Normal file
43
netbox/ipam/tests/test_forms.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from ipam.forms import PrefixForm
|
||||
|
||||
|
||||
class PrefixFormTestCase(TestCase):
|
||||
default_dynamic_params = '[{"fieldName":"scope","queryParam":"available_at_site"}]'
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
def test_vlan_field_sets_dynamic_params_by_default(self):
|
||||
"""data-dynamic-params present when no scope_type selected"""
|
||||
form = PrefixForm(data={})
|
||||
|
||||
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
|
||||
|
||||
def test_vlan_field_sets_dynamic_params_for_scope_site(self):
|
||||
"""data-dynamic-params present when scope type is Site and when scope is specifc site"""
|
||||
form = PrefixForm(data={
|
||||
'scope_type': ContentType.objects.get_for_model(Site).id,
|
||||
'scope': self.site,
|
||||
})
|
||||
|
||||
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
|
||||
|
||||
def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
|
||||
"""data-dynamic-params not present when scope type is populated by is not Site"""
|
||||
cases = [
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
Location(site=self.site, name='Location 1', slug='location-1'),
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
]
|
||||
for case in cases:
|
||||
form = PrefixForm(data={
|
||||
'scope_type': ContentType.objects.get_for_model(case._meta.model).id,
|
||||
'scope': case,
|
||||
})
|
||||
|
||||
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
|
||||
@@ -666,6 +666,24 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
fhrp_groups = (
|
||||
FHRPGroup(
|
||||
name='FHRP Group 1',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=10
|
||||
),
|
||||
FHRPGroup(
|
||||
name='FHRP Group 2',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=20
|
||||
),
|
||||
FHRPGroup(
|
||||
name='FHRP Group 3',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=30
|
||||
),
|
||||
)
|
||||
FHRPGroup.objects.bulk_create(fhrp_groups)
|
||||
cls.form_data = {
|
||||
'vrf': vrfs[1].pk,
|
||||
'address': IPNetwork('192.0.2.99/24'),
|
||||
@@ -679,10 +697,10 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"vrf,address,status",
|
||||
"VRF 1,192.0.2.4/24,active",
|
||||
"VRF 1,192.0.2.5/24,active",
|
||||
"VRF 1,192.0.2.6/24,active",
|
||||
"vrf,address,status,fhrp_group",
|
||||
"VRF 1,192.0.2.4/24,active,FHRP Group 1",
|
||||
"VRF 1,192.0.2.5/24,active,FHRP Group 2",
|
||||
"VRF 1,192.0.2.6/24,active,FHRP Group 3",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -852,6 +852,7 @@ class IPAddressEditView(generic.ObjectEditView):
|
||||
return {'interface': request.GET['interface']}
|
||||
elif 'vminterface' in request.GET:
|
||||
return {'vminterface': request.GET['vminterface']}
|
||||
return {}
|
||||
|
||||
|
||||
# TODO: Standardize or remove this view
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.query import reapply_model_ordering
|
||||
from . import mixins
|
||||
|
||||
__all__ = (
|
||||
@@ -121,6 +122,10 @@ class NetBoxModelViewSet(
|
||||
obj.snapshot()
|
||||
return obj
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
return reapply_model_ordering(qs)
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
if isinstance(kwargs.get('data', {}), list):
|
||||
|
||||
@@ -28,7 +28,7 @@ AUTH_BACKEND_ATTRS = {
|
||||
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
|
||||
'digitalocean': ('DigitalOcean', 'digital-ocean'),
|
||||
'docker': ('Docker', 'docker'),
|
||||
'github': ('GitHub', 'docker'),
|
||||
'github': ('GitHub', 'github'),
|
||||
'github-app': ('GitHub', 'github'),
|
||||
'github-org': ('GitHub', 'github'),
|
||||
'github-team': ('GitHub', 'github'),
|
||||
|
||||
@@ -226,6 +226,11 @@ SESSION_COOKIE_NAME = 'sessionid'
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
SESSION_FILE_PATH = None
|
||||
|
||||
# By default the memory and disk sizes are displayed using base 10 (e.g. 1000 MB = 1 GB).
|
||||
# If you would like to use base 2 (e.g. 1024 MB = 1 GB) set this to 1024.
|
||||
# DISK_BASE_UNIT = 1024
|
||||
# RAM_BASE_UNIT = 1024
|
||||
|
||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
|
||||
@@ -169,15 +169,6 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
|
||||
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit saved filters to those applicable to the form's model
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
self.fields['filter_id'].widget.add_query_params({
|
||||
'object_type_id': object_type.pk,
|
||||
})
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return super()._get_custom_fields(content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
|
||||
@@ -73,6 +73,16 @@ class SavedFiltersMixin(forms.Form):
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit saved filters to those applicable to the form's model
|
||||
if hasattr(self, 'model'):
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
self.fields['filter_id'].widget.add_query_params({
|
||||
'object_type_id': object_type.pk,
|
||||
})
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from netbox.models.features import *
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.views import get_viewname
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -42,7 +43,7 @@ class NetBoxFeatureSet(
|
||||
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(f'{self._meta.app_label}:{self._meta.model_name}', args=[self.pk])
|
||||
return reverse(get_viewname(self), args=[self.pk])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -190,6 +190,12 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None)
|
||||
STORAGES = getattr(configuration, 'STORAGES', {})
|
||||
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
|
||||
DISK_BASE_UNIT = getattr(configuration, 'DISK_BASE_UNIT', 1000)
|
||||
if DISK_BASE_UNIT not in [1000, 1024]:
|
||||
raise ImproperlyConfigured(f"DISK_BASE_UNIT must be 1000 or 1024 (found {DISK_BASE_UNIT})")
|
||||
RAM_BASE_UNIT = getattr(configuration, 'RAM_BASE_UNIT', 1000)
|
||||
if RAM_BASE_UNIT not in [1000, 1024]:
|
||||
raise ImproperlyConfigured(f"RAM_BASE_UNIT must be 1000 or 1024 (found {RAM_BASE_UNIT})")
|
||||
|
||||
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
|
||||
for param in CONFIG_PARAMS:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dummy_plugin', '0001_initial'),
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DummyNetBoxModel',
|
||||
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),
|
||||
),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from netbox.models import NetBoxModel
|
||||
|
||||
|
||||
class DummyModel(models.Model):
|
||||
name = models.CharField(
|
||||
@@ -11,3 +13,7 @@ class DummyModel(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
class DummyNetBoxModel(NetBoxModel):
|
||||
pass
|
||||
|
||||
@@ -6,4 +6,6 @@ from . import views
|
||||
urlpatterns = (
|
||||
path('models/', views.DummyModelsView.as_view(), name='dummy_model_list'),
|
||||
path('models/add/', views.DummyModelAddView.as_view(), name='dummy_model_add'),
|
||||
|
||||
path('netboxmodel/<int:pk>/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel'),
|
||||
)
|
||||
|
||||
@@ -5,12 +5,17 @@ from django.http import HttpResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Site
|
||||
from netbox.views import generic
|
||||
from utilities.views import register_model_view
|
||||
from .models import DummyModel
|
||||
from .models import DummyModel, DummyNetBoxModel
|
||||
# Trigger registration of custom column
|
||||
from .tables import mycol # noqa: F401
|
||||
|
||||
|
||||
#
|
||||
# DummyModel
|
||||
#
|
||||
|
||||
class DummyModelsView(View):
|
||||
|
||||
def get(self, request):
|
||||
@@ -32,6 +37,18 @@ class DummyModelAddView(View):
|
||||
return HttpResponse("Instance created")
|
||||
|
||||
|
||||
#
|
||||
# DummyNetBoxModel
|
||||
#
|
||||
|
||||
class DummyNetBoxModelView(generic.ObjectView):
|
||||
queryset = DummyNetBoxModel.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# API
|
||||
#
|
||||
|
||||
@register_model_view(Site, 'extra', path='other-stuff')
|
||||
class ExtraCoreModelView(View):
|
||||
|
||||
|
||||
23
netbox/netbox/tests/test_models.py
Normal file
23
netbox/netbox/tests/test_models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import ObjectChange
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
|
||||
class ModelTest(TestCase):
|
||||
|
||||
def test_get_absolute_url(self):
|
||||
m = ObjectChange()
|
||||
m.pk = 123
|
||||
|
||||
self.assertEqual(m.get_absolute_url(), f'/core/changelog/{m.pk}/')
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
def test_get_absolute_url_plugin(self):
|
||||
m = DummyNetBoxModel()
|
||||
m.pk = 123
|
||||
|
||||
self.assertEqual(m.get_absolute_url(), f'/plugins/dummy-plugin/netboxmodel/{m.pk}/')
|
||||
@@ -28,6 +28,7 @@ from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fiel
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.views import GetReturnURLMixin, get_viewname
|
||||
from .base import BaseMultiObjectView
|
||||
from .mixins import ActionsMixin, TableMixin
|
||||
@@ -125,6 +126,10 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
# Request handlers
|
||||
#
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return reapply_model_ordering(qs)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET request handler.
|
||||
@@ -679,7 +684,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
elif 'virtual_machine' in request.GET:
|
||||
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
|
||||
|
||||
form = self.form(request.POST, initial=initial_data)
|
||||
post_data = request.POST.copy()
|
||||
post_data.setlist('pk', pk_list)
|
||||
form = self.form(post_data, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
if '_apply' in request.POST:
|
||||
|
||||
@@ -172,7 +172,7 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
|
||||
|
||||
def get_jobs(self, instance):
|
||||
object_type = ContentType.objects.get_for_model(instance)
|
||||
return Job.objects.filter(
|
||||
return Job.objects.defer('data').filter(
|
||||
object_type=object_type,
|
||||
object_id=instance.id
|
||||
)
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -27,11 +27,11 @@
|
||||
"bootstrap": "5.3.3",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "11.3.0",
|
||||
"gridstack": "11.5.0",
|
||||
"htmx.org": "2.0.4",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.83.4",
|
||||
"tom-select": "2.4.2",
|
||||
"sass": "1.86.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
},
|
||||
|
||||
@@ -43,7 +43,9 @@ function toggleCheckboxRange(
|
||||
const typedElement = element as HTMLInputElement;
|
||||
//Change loop's current checkbox state to eventTargetElement checkbox state
|
||||
if (changePkCheckboxState === true) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
}
|
||||
//The previously clicked checkbox was above the shift clicked checkbox
|
||||
if (element === previousStateElement) {
|
||||
@@ -52,7 +54,9 @@ function toggleCheckboxRange(
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
}
|
||||
//The previously clicked checkbox was below the shift clicked checkbox
|
||||
if (element === eventTargetElement) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initForms } from './forms';
|
||||
import { initButtons } from './buttons';
|
||||
import { initClipboard } from './clipboard'
|
||||
import { initClipboard } from './clipboard';
|
||||
import { initSelects } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
import { initBootstrap } from './bs';
|
||||
@@ -9,6 +10,7 @@ import { initQuickAdd } from './quickAdd';
|
||||
function initDepedencies(): void {
|
||||
initButtons();
|
||||
initClipboard();
|
||||
initForms();
|
||||
initSelects();
|
||||
initObjectSelector();
|
||||
initQuickAdd();
|
||||
|
||||
@@ -38,7 +38,7 @@ span.color-label {
|
||||
.btn-float-group {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.btn-float-group-left {
|
||||
|
||||
@@ -1911,10 +1911,10 @@ graphql@16.10.0:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
||||
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
||||
|
||||
gridstack@11.3.0:
|
||||
version "11.3.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.3.0.tgz#b110c66bafc64c920fc54933e2c9df4f7b2cfffe"
|
||||
integrity sha512-Z0eRovKcZTRTs3zetJwjO6CNwrgIy845WfOeZGk8ybpeMCE8fMA8tScyKU72Y2M6uGHkjgwnjflglvPiv+RcBQ==
|
||||
gridstack@11.5.0:
|
||||
version "11.5.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.5.0.tgz#ecd507776db857f3308d37a8fd67d6a24c7fdd74"
|
||||
integrity sha512-SE1a/aC2K8VKQr5cqV7gSJ+r/xIYghijIjHzkZ3Xo3aS1/4dvwIgPYT7QqgV1z+d7XjKYUPEizcgVQ5HhdFTng==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.83.4:
|
||||
version "1.83.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1"
|
||||
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==
|
||||
sass@1.86.0:
|
||||
version "1.86.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114"
|
||||
integrity sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
@@ -2882,10 +2882,10 @@ toggle-selection@^1.0.6:
|
||||
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
|
||||
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
|
||||
|
||||
tom-select@2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.2.tgz#9764faf6cba51f6571d03a79bb7c1cac1cac7a5a"
|
||||
integrity sha512-2RWjkL3gMDz9E+u8w+tQy9JWsYq8gaSytEVeugKYDeMus6ZtxT1HttLPnXsfHCnBPlsNubVyj5gtUeN+S+bcpA==
|
||||
tom-select@2.4.3:
|
||||
version "2.4.3"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
|
||||
integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
|
||||
dependencies:
|
||||
"@orchidjs/sifter" "^1.1.0"
|
||||
"@orchidjs/unicode-variants" "^1.1.2"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user