mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-19 03:42:25 -06:00
Merge branch 'develop' into feature
This commit is contained in:
@@ -119,6 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -165,6 +165,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=SiteStatusChoices,
|
||||
@@ -248,6 +249,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -420,6 +422,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -544,6 +547,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -620,6 +624,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Platform
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -654,6 +659,7 @@ class DeviceFilterForm(
|
||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||
))
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -997,6 +1003,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -1228,6 +1235,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id')
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='starting_unit',
|
||||
field=models.PositiveSmallIntegerField(default=1),
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
@@ -994,11 +994,17 @@ class Device(
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
model = queryset.model.component_model
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
model = components[0]._meta.model
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
for component in components:
|
||||
@@ -1011,8 +1017,7 @@ class Device(
|
||||
update_fields=None
|
||||
)
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
for component in components:
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
|
||||
@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
starting_unit = models.PositiveSmallIntegerField(
|
||||
default=RACK_STARTING_UNIT_DEFAULT,
|
||||
verbose_name=_('starting unit'),
|
||||
validators=[MinValueValidator(1),],
|
||||
help_text=_('Starting unit for rack')
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from tenancy.models import Tenant
|
||||
from utilities.utils import drange
|
||||
|
||||
@@ -289,6 +291,23 @@ class DeviceTestCase(TestCase):
|
||||
)
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
# Create a CustomField with a default value & assign it to all component models
|
||||
cf1 = CustomField.objects.create(name='cf1', default='foo')
|
||||
cf1.content_types.set(
|
||||
ContentType.objects.filter(app_label='dcim', model__in=[
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
'powerport',
|
||||
'poweroutlet',
|
||||
'interface',
|
||||
'rearport',
|
||||
'frontport',
|
||||
'modulebay',
|
||||
'devicebay',
|
||||
'inventoryitem',
|
||||
])
|
||||
)
|
||||
|
||||
# Create DeviceType components
|
||||
ConsolePortTemplate(
|
||||
device_type=device_type,
|
||||
@@ -300,18 +319,18 @@ class DeviceTestCase(TestCase):
|
||||
name='Console Server Port 1'
|
||||
).save()
|
||||
|
||||
ppt = PowerPortTemplate(
|
||||
powerport = PowerPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
ppt.save()
|
||||
powerport.save()
|
||||
|
||||
PowerOutletTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Outlet 1',
|
||||
power_port=ppt,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
).save()
|
||||
|
||||
@@ -322,19 +341,19 @@ class DeviceTestCase(TestCase):
|
||||
mgmt_only=True
|
||||
).save()
|
||||
|
||||
rpt = RearPortTemplate(
|
||||
rearport = RearPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
rpt.save()
|
||||
rearport.save()
|
||||
|
||||
FrontPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rpt,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
).save()
|
||||
|
||||
@@ -348,73 +367,93 @@ class DeviceTestCase(TestCase):
|
||||
name='Device Bay 1'
|
||||
).save()
|
||||
|
||||
InventoryItemTemplate(
|
||||
device_type=device_type,
|
||||
name='Inventory Item 1'
|
||||
).save()
|
||||
|
||||
def test_device_creation(self):
|
||||
"""
|
||||
Ensure that all Device components are copied automatically from the DeviceType.
|
||||
"""
|
||||
d = Device(
|
||||
device = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
d.save()
|
||||
device.save()
|
||||
|
||||
ConsolePort.objects.get(
|
||||
device=d,
|
||||
consoleport = ConsolePort.objects.get(
|
||||
device=device,
|
||||
name='Console Port 1'
|
||||
)
|
||||
self.assertEqual(consoleport.cf['cf1'], 'foo')
|
||||
|
||||
ConsoleServerPort.objects.get(
|
||||
device=d,
|
||||
consoleserverport = ConsoleServerPort.objects.get(
|
||||
device=device,
|
||||
name='Console Server Port 1'
|
||||
)
|
||||
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
|
||||
|
||||
pp = PowerPort.objects.get(
|
||||
device=d,
|
||||
powerport = PowerPort.objects.get(
|
||||
device=device,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
self.assertEqual(powerport.cf['cf1'], 'foo')
|
||||
|
||||
PowerOutlet.objects.get(
|
||||
device=d,
|
||||
poweroutlet = PowerOutlet.objects.get(
|
||||
device=device,
|
||||
name='Power Outlet 1',
|
||||
power_port=pp,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
)
|
||||
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
|
||||
|
||||
Interface.objects.get(
|
||||
device=d,
|
||||
interface = Interface.objects.get(
|
||||
device=device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
)
|
||||
self.assertEqual(interface.cf['cf1'], 'foo')
|
||||
|
||||
rp = RearPort.objects.get(
|
||||
device=d,
|
||||
rearport = RearPort.objects.get(
|
||||
device=device,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
self.assertEqual(rearport.cf['cf1'], 'foo')
|
||||
|
||||
FrontPort.objects.get(
|
||||
device=d,
|
||||
frontport = FrontPort.objects.get(
|
||||
device=device,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rp,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
)
|
||||
self.assertEqual(frontport.cf['cf1'], 'foo')
|
||||
|
||||
ModuleBay.objects.get(
|
||||
device=d,
|
||||
modulebay = ModuleBay.objects.get(
|
||||
device=device,
|
||||
name='Module Bay 1'
|
||||
)
|
||||
self.assertEqual(modulebay.cf['cf1'], 'foo')
|
||||
|
||||
DeviceBay.objects.get(
|
||||
device=d,
|
||||
devicebay = DeviceBay.objects.get(
|
||||
device=device,
|
||||
name='Device Bay 1'
|
||||
)
|
||||
self.assertEqual(devicebay.cf['cf1'], 'foo')
|
||||
|
||||
inventoryitem = InventoryItem.objects.get(
|
||||
device=device,
|
||||
name='Inventory Item 1'
|
||||
)
|
||||
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
|
||||
|
||||
def test_multiple_unnamed_devices(self):
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -82,7 +84,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
extra_choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
help_text=_('Comma-separated list of field choices')
|
||||
help_text=_(
|
||||
'Quoted string of comma-separated field choices with optional labels separated by colon: '
|
||||
'"choice1:First Choice,choice2:Second Choice"'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -91,6 +96,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||
)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
if isinstance(self.cleaned_data['extra_choices'], list):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices']:
|
||||
try:
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
return data
|
||||
|
||||
|
||||
class CustomLinkImportForm(CSVModelForm):
|
||||
content_types = CSVMultipleContentTypeField(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -88,19 +89,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
required=False,
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
'colon. Example:'
|
||||
) + ' <code>choice1:First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
# Escape colons in extra_choices
|
||||
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)
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices'].splitlines():
|
||||
try:
|
||||
value, label = line.split(',', maxsplit=1)
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
|
||||
@@ -55,6 +55,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
def get_defaults_for_model(self, model):
|
||||
"""
|
||||
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
|
||||
"""
|
||||
custom_fields = self.get_for_model(model).filter(default__isnull=False)
|
||||
return {
|
||||
cf.name: cf.default for cf in custom_fields
|
||||
}
|
||||
|
||||
|
||||
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
|
||||
@@ -92,19 +92,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
name='Choice Set 3',
|
||||
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
|
||||
),
|
||||
CustomFieldChoiceSet(
|
||||
name='Choice Set 4',
|
||||
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
|
||||
),
|
||||
)
|
||||
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Choice Set X',
|
||||
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
|
||||
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,extra_choices',
|
||||
'Choice Set 4,"D1,D2,D3"',
|
||||
'Choice Set 5,"E1,E2,E3"',
|
||||
'Choice Set 6,"F1,F2,F3"',
|
||||
'Choice Set 5,"D1,D2,D3"',
|
||||
'Choice Set 6,"E1,E2,E3"',
|
||||
'Choice Set 7,"F1,F2,F3"',
|
||||
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -112,12 +117,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
f'{choice_sets[0].pk},"A,B,C"',
|
||||
f'{choice_sets[1].pk},"A,B,C"',
|
||||
f'{choice_sets[2].pk},"A,B,C"',
|
||||
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
# This is here as extra_choices field splits on colon, but is returned
|
||||
# from DB as comma separated.
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
if 'extra_choices' in data:
|
||||
data['extra_choices'] = data['extra_choices'].replace(':', ',')
|
||||
return super().assertInstanceEqual(instance, data, exclude, api)
|
||||
|
||||
|
||||
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = CustomLink
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -277,7 +279,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
# Prepare object data for deserialization
|
||||
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
|
||||
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||
|
||||
@@ -296,6 +296,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -448,6 +449,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -951,7 +951,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
if not get_table_ordering(request, self.table):
|
||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||
return add_available_vlans(queryset, parent)
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -56,8 +56,15 @@ class BriefModeMixin:
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
serializer_class = self.get_serializer_class()
|
||||
|
||||
# Clear any annotations for fields not present on the nested serializer
|
||||
for annotation in list(qs.query.annotations.keys()):
|
||||
if annotation not in serializer_class().fields:
|
||||
qs.query.annotations.pop(annotation)
|
||||
|
||||
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return qs
|
||||
|
||||
@@ -144,12 +144,16 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi
|
||||
model: The model class associated with the form
|
||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||
a selector widget
|
||||
"""
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -264,6 +264,11 @@ export class APISelect {
|
||||
switch (this.trigger) {
|
||||
case 'collapse':
|
||||
if (collapse !== null) {
|
||||
// If the element is collapsible but already shown, load the data immediately.
|
||||
if (collapse.classList.contains('show')) {
|
||||
Promise.all([this.loadData()]);
|
||||
}
|
||||
|
||||
// If this element is part of a collapsible element, only load the data when the
|
||||
// collapsible element is shown.
|
||||
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% comment %}
|
||||
Include a hidden field of the same name to ensure that unchecked checkboxes
|
||||
are always included in the submitted form data.
|
||||
are always included in the submitted form data. Omit fields names
|
||||
_selected_action to avoid breaking the admin UI.
|
||||
{% endcomment %}
|
||||
<input type="hidden" name="{{ widget.name }}" value="">
|
||||
{% if widget.name != '_selected_action' %}<input type="hidden" name="{{ widget.name }}" value="">{% endif %}
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% for field in form.visible_fields %}
|
||||
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
|
||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 or field.name in form.selector_fields %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||
{{ field.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
|
||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, change, keyup from:#id_q delay:500ms">
|
||||
<input type="hidden" name="_search" value="true" />
|
||||
<div class="tab-content p-1">
|
||||
{% for field in form.visible_fields %}
|
||||
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||
<div class="collapse{% if field.name in form.selector_fields %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
|
||||
@@ -90,6 +90,19 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
queryset=Contact.objects.all(),
|
||||
label=_('Contact (ID)'),
|
||||
)
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group (ID)'),
|
||||
)
|
||||
group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Contact group (slug)'),
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContactRole.objects.all(),
|
||||
label=_('Contact role (ID)'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Manufacturer, Site
|
||||
from tenancy.filtersets import *
|
||||
from tenancy.models import *
|
||||
from utilities.testing import ChangeLoggedFilterSetTests
|
||||
@@ -192,3 +194,72 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [group[0].slug, group[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ContactAssignment.objects.all()
|
||||
filterset = ContactAssignmentFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
contact_groups = (
|
||||
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
|
||||
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
|
||||
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
|
||||
)
|
||||
for contactgroup in contact_groups:
|
||||
contactgroup.save()
|
||||
|
||||
contact_roles = (
|
||||
ContactRole(name='Contact Role 1', slug='contact-role-1'),
|
||||
ContactRole(name='Contact Role 2', slug='contact-role-2'),
|
||||
ContactRole(name='Contact Role 3', slug='contact-role-3'),
|
||||
)
|
||||
ContactRole.objects.bulk_create(contact_roles)
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0]),
|
||||
Contact(name='Contact 2', group=contact_groups[1]),
|
||||
Contact(name='Contact 3', group=contact_groups[2]),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
|
||||
assignments = (
|
||||
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
|
||||
ContactAssignment(object=sites[1], contact=contacts[1], role=contact_roles[1]),
|
||||
ContactAssignment(object=sites[2], contact=contacts[2], role=contact_roles[2]),
|
||||
ContactAssignment(object=manufacturer, contact=contacts[2], role=contact_roles[2]),
|
||||
)
|
||||
ContactAssignment.objects.bulk_create(assignments)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_contact(self):
|
||||
contacts = Contact.objects.all()[:2]
|
||||
params = {'contact_id': [contacts[0].pk, contacts[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_group(self):
|
||||
group = ContactGroup.objects.all()[:2]
|
||||
params = {'group_id': [group[0].pk, group[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'group': [group[0].slug, group[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_role(self):
|
||||
role = ContactRole.objects.all()[:2]
|
||||
params = {'role_id': [role[0].pk, role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'role': [role[0].slug, role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -220,6 +220,7 @@ class UserConfig(models.Model):
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@receiver(post_save, sender=NetBoxUser)
|
||||
def create_userconfig(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import apps
|
||||
from django.db.models import F, Count, OuterRef, Subquery
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
|
||||
from netbox.registry import registry
|
||||
from .fields import CounterCacheField
|
||||
@@ -62,6 +62,12 @@ def post_save_receiver(sender, instance, created, **kwargs):
|
||||
update_counter(parent_model, new_pk, counter_name, 1)
|
||||
|
||||
|
||||
def pre_delete_receiver(sender, instance, origin, **kwargs):
|
||||
model = instance._meta.model
|
||||
if not model.objects.filter(pk=instance.pk).exists():
|
||||
instance._previously_removed = True
|
||||
|
||||
|
||||
def post_delete_receiver(sender, instance, origin, **kwargs):
|
||||
"""
|
||||
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
|
||||
@@ -71,10 +77,8 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
|
||||
parent_pk = getattr(instance, field_name, None)
|
||||
|
||||
# Decrement the parent's counter by one
|
||||
if parent_pk is not None:
|
||||
# MPTT sends two delete signals for child elements so guard against multiple decrements
|
||||
if not origin or origin == instance:
|
||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||
if parent_pk is not None and not hasattr(instance, "_previously_removed"):
|
||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||
|
||||
|
||||
#
|
||||
@@ -106,6 +110,12 @@ def connect_counters(*models):
|
||||
weak=False,
|
||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||
)
|
||||
pre_delete.connect(
|
||||
pre_delete_receiver,
|
||||
sender=to_model,
|
||||
weak=False,
|
||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||
)
|
||||
post_delete.connect(
|
||||
post_delete_receiver,
|
||||
sender=to_model,
|
||||
|
||||
@@ -65,5 +65,5 @@ class ChoicesWidget(forms.Textarea):
|
||||
if not value:
|
||||
return None
|
||||
if type(value) is list:
|
||||
return '\n'.join([f'{k},{v}' for k, v in value])
|
||||
return '\n'.join([f'{k}:{v}' for k, v in value])
|
||||
return value
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from netaddr import IPAddress
|
||||
from netaddr import AddrFormatError, IPAddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
__all__ = (
|
||||
'get_client_ip',
|
||||
@@ -17,11 +18,18 @@ def get_client_ip(request, additional_headers=()):
|
||||
)
|
||||
for header in HTTP_HEADERS:
|
||||
if header in request.META:
|
||||
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
||||
ip = request.META[header].split(',')[0].strip()
|
||||
try:
|
||||
return IPAddress(client_ip)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
|
||||
return IPAddress(ip)
|
||||
except AddrFormatError:
|
||||
# Parse the string with urlparse() to remove port number or any other cruft
|
||||
ip = urlparse(f'//{ip}').hostname
|
||||
|
||||
try:
|
||||
return IPAddress(ip)
|
||||
except AddrFormatError:
|
||||
# We did our best
|
||||
raise ValueError(f"Invalid IP address set for {header}: {ip}")
|
||||
|
||||
# Could not determine the client IP address from request headers
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||
<a class="btn btn-sm {{ color }} copy-content {{ classes }}" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
</a>
|
||||
|
||||
@@ -87,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
|
||||
|
||||
|
||||
@register.inclusion_tag('builtins/copy_content.html')
|
||||
def copy_content(target, prefix=None, color='primary'):
|
||||
def copy_content(target, prefix=None, color='primary', classes=None):
|
||||
"""
|
||||
Display a copy button to copy the content of a field.
|
||||
"""
|
||||
return {
|
||||
'target': f'#{prefix or ""}{target}',
|
||||
'color': f'btn-{color}'
|
||||
'color': f'btn-{color}',
|
||||
'classes': classes or '',
|
||||
}
|
||||
|
||||
|
||||
|
||||
28
netbox/utilities/tests/test_request.py
Normal file
28
netbox/utilities/tests/test_request.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from netaddr import IPAddress
|
||||
from utilities.request import get_client_ip
|
||||
|
||||
|
||||
class GetClientIPTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_ipv4_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||
|
||||
def test_ipv6_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080')
|
||||
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||
|
||||
def test_invalid_ip_address(self):
|
||||
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip')
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_ip(request)
|
||||
@@ -46,6 +46,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'group_id')
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ClusterType.objects.all(),
|
||||
required=False,
|
||||
@@ -188,6 +189,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
||||
(_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')),
|
||||
(_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -441,7 +441,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('l2vpn', )
|
||||
fields = ('l2vpn', 'tags')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
|
||||
@@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Object Site')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:l2vpntermination_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPNTermination
|
||||
fields = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
|
||||
'actions',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
|
||||
|
||||
Reference in New Issue
Block a user