mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-21 11:37:21 -06:00
commit
6ac25eeb65
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.4
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.4
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
name: 🌍 Translation
|
||||
description: Request support for a new language in the user interface
|
||||
labels: ["type: translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||
not use it to request changes to existing translations.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What is the name of the language in English?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: ISO 639-1 code
|
||||
description: >
|
||||
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||
assigned to the language?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Volunteer
|
||||
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Comments
|
||||
description: Any other notes you would like to share
|
@ -53,7 +53,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||
django-taggit<5.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
|
@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
]
|
||||
},
|
||||
{
|
||||
"attr": "tags",
|
||||
"attr": "tags.slug",
|
||||
"value": "exempt",
|
||||
"op": "contains"
|
||||
}
|
||||
|
@ -1,5 +1,35 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||
|
||||
---
|
||||
|
||||
## v3.6.4 (2023-10-17)
|
||||
|
||||
### Enhancements
|
||||
|
@ -1,11 +1,20 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command to clear the entire cache."""
|
||||
help = 'Clears the cache.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Fetch the current config revision from the cache
|
||||
config_version = cache.get('config_version')
|
||||
# Clear the cache
|
||||
cache.clear()
|
||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||
if config_version:
|
||||
# Activate the current config revision
|
||||
ConfigRevision.objects.get(id=config_version).activate()
|
||||
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||
|
@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_device_bays',
|
||||
label=_('Has device bays'),
|
||||
)
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
|
@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||
"number of rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front ports to be created ({frontport_count}) must match the selected number of "
|
||||
"rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
|
@ -180,6 +180,17 @@ class Cable(PrimaryModel):
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||
|
||||
if (a_pks & b_pks):
|
||||
raise ValidationError(
|
||||
_("A and B terminations cannot connect to the same object.")
|
||||
)
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
|
@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
maximum_draw = tables.Column(
|
||||
verbose_name=_('Maximum draw (W)')
|
||||
)
|
||||
allocated_draw = tables.Column(
|
||||
verbose_name=_('Allocated draw (W)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
verbose_name=_('VRF'),
|
||||
linkify=True
|
||||
)
|
||||
inventory_items = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name=_('Inventory Items'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
@ -636,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', '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',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
discovered = columns.BooleanColumn(
|
||||
verbose_name=_('Discovered'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Parent'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Tenant')
|
||||
)
|
||||
site = tables.Column(
|
||||
accessor='rack__site',
|
||||
linkify=True,
|
||||
verbose_name=_('Site'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
|
||||
'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||
'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = (
|
||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||
IPAddress(assigned_object=None, address='10.1.1.3/24'),
|
||||
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
|
||||
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
|
||||
IPAddress(assigned_object=None, address='2001:db8::3/64'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(addresses)
|
||||
|
||||
vdcs[0].primary_ip4 = addresses[0]
|
||||
vdcs[0].primary_ip6 = addresses[3]
|
||||
vdcs[0].save()
|
||||
vdcs[1].primary_ip4 = addresses[1]
|
||||
vdcs[1].primary_ip6 = addresses[4]
|
||||
vdcs[1].save()
|
||||
|
||||
def test_device(self):
|
||||
@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
@ -2993,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'children')
|
||||
class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
child_model = InventoryItem
|
||||
table = tables.InventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Children'),
|
||||
badge=lambda obj: obj.child_items.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
hide_if_empty=True,
|
||||
weight=5000
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.child_items.restrict(request.user, 'view')
|
||||
|
||||
|
||||
#
|
||||
# Inventory item roles
|
||||
#
|
||||
|
@ -457,7 +457,7 @@ class ConfigContextTestCase(
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'device_types': [devicetype.id],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ __all__ = (
|
||||
'L2VPNFilterSet',
|
||||
'L2VPNTerminationFilterSet',
|
||||
'PrefixFilterSet',
|
||||
'PrimaryIPFilterSet',
|
||||
'RIRFilterSet',
|
||||
'RoleFilterSet',
|
||||
'RouteTargetFilterSet',
|
||||
@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='net_mask_length'
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
method='filter_mask_length',
|
||||
label=_('Mask length'),
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__gte'
|
||||
)
|
||||
mask_length__lte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__lte'
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VRF.objects.all(),
|
||||
@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def filter_mask_length(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
An inheritable FilterSet for models which support primary IP assignment.
|
||||
"""
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
|
@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
|
||||
choices=ServiceProtocolChoices,
|
||||
help_text=_('IP protocol')
|
||||
)
|
||||
ipaddresses = CSVModelMultipleChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
to_field_name='address',
|
||||
help_text=_('IP Address'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def clean_ipaddresses(self):
|
||||
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||
for ip_address in self.cleaned_data['ipaddresses']:
|
||||
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
||||
raise forms.ValidationError(
|
||||
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
|
||||
)
|
||||
|
||||
return self.cleaned_data['ipaddresses']
|
||||
|
||||
|
||||
class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('protocol', 'port')),
|
||||
(_('Assignment'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Machine'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': ['24']}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'mask_length__gte': 32}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__lte': 24}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__gte': 64}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'mask_length__lte': 25}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
|
@ -4,6 +4,7 @@ from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.constants import InterfaceTypeChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
|
||||
services = (
|
||||
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||
@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
Service.objects.bulk_create(services)
|
||||
|
||||
ip_addresses = (
|
||||
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,protocol,ports,description",
|
||||
"Device 1,Service 1,tcp,1,First service",
|
||||
"Device 1,Service 2,tcp,2,Second service",
|
||||
"Device 1,Service 3,udp,3,Third service",
|
||||
"device,name,protocol,ports,ipaddresses,description",
|
||||
"Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||
"Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||
"Device 1,Service 3,udp,3,,Third service",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('ASNs'),
|
||||
badge=lambda x: x.get_child_asns().count(),
|
||||
permission='ipam.view_asns',
|
||||
permission='ipam.view_asn',
|
||||
weight=500
|
||||
)
|
||||
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.4'
|
||||
VERSION = '3.6.5'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
|
||||
MAINTENANCE_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}admin/',
|
||||
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||
LOGIN_URL,
|
||||
LOGIN_REDIRECT_URL,
|
||||
LOGOUT_REDIRECT_URL
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
|
@ -119,7 +119,7 @@ class BaseTable(tables.Table):
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
return sorted(self._get_columns(visible=False))
|
||||
|
||||
@property
|
||||
def selected_columns(self):
|
||||
|
@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
|
||||
verbose_name=_('Role'),
|
||||
linkify=True
|
||||
)
|
||||
contact_group = tables.Column(
|
||||
accessor=Accessor('contact__group'),
|
||||
verbose_name=_('Group'),
|
||||
linkify=True
|
||||
)
|
||||
contact_title = tables.Column(
|
||||
accessor=Accessor('contact__title'),
|
||||
verbose_name=_('Contact Title')
|
||||
@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
|
||||
model = ContactAssignment
|
||||
fields = (
|
||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
|
||||
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions'
|
||||
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
|
||||
'actions'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
|
||||
|
@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.ContactAssignmentFilterSet
|
||||
filterset_form = forms.ContactAssignmentFilterForm
|
||||
table = tables.ContactAssignmentTable
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete')
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete', 'import')
|
||||
|
||||
|
||||
@register_model_view(ContactAssignment, 'edit')
|
||||
|
@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
|
||||
)
|
||||
for header in HTTP_HEADERS:
|
||||
if header in request.META:
|
||||
client_ip = request.META[header].split(',')[0]
|
||||
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
||||
try:
|
||||
return IPAddress(client_ip)
|
||||
except ValueError:
|
||||
|
@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||
@ -114,7 +115,8 @@ class VirtualMachineFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=VirtualMachineStatusChoices,
|
||||
|
@ -200,7 +200,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
local_context_data = JSONField(
|
||||
required=False,
|
||||
|
@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
IPAddress(address='192.0.2.3/24', assigned_object=None),
|
||||
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
|
||||
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
|
||||
IPAddress(address='2001:db8::3/64', assigned_object=None),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
|
||||
@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
|
||||
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VMInterface.objects.all()
|
||||
|
@ -1,5 +1,5 @@
|
||||
bleach==6.1.0
|
||||
Django==4.2.6
|
||||
Django==4.2.7
|
||||
django-cors-headers==4.3.0
|
||||
django-debug-toolbar==4.2.0
|
||||
django-filter==23.3
|
||||
@ -21,16 +21,16 @@ graphene-django==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.4.6
|
||||
mkdocs-material==9.4.8
|
||||
mkdocstrings[python-legacy]==0.23.0
|
||||
netaddr==0.9.0
|
||||
Pillow==10.1.0
|
||||
psycopg[binary,pool]==3.1.12
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
sentry-sdk==1.32.0
|
||||
sentry-sdk==1.34.0
|
||||
social-auth-app-django==5.4.0
|
||||
social-auth-core[openidconnect]==4.4.2
|
||||
social-auth-core[openidconnect]==4.5.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.5.0
|
||||
tzdata==2023.3
|
||||
|
Loading…
Reference in New Issue
Block a user