mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-22 13:22:24 -06:00
Merge branch 'main' into feature
This commit is contained in:
@@ -28,6 +28,7 @@ from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from users import forms, tables
|
||||
from users.models import UserConfig
|
||||
from utilities.string import remove_linebreaks
|
||||
from utilities.views import register_model_view
|
||||
|
||||
|
||||
@@ -125,12 +126,18 @@ class LoginView(View):
|
||||
|
||||
# Set the user's preferred language (if any)
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||
username = form['username'].value()
|
||||
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@@ -142,10 +149,10 @@ class LoginView(View):
|
||||
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
||||
logger.debug(f"Redirecting user to {redirect_url}")
|
||||
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
|
||||
else:
|
||||
if redirect_url:
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
|
||||
redirect_url = reverse('home')
|
||||
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
@@ -220,7 +227,12 @@ class UserConfigView(LoginRequiredMixin, View):
|
||||
|
||||
# Set/clear language cookie
|
||||
if language := form.cleaned_data['locale.language']:
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
else:
|
||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||
|
||||
|
||||
@@ -159,11 +159,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(CircuitTermination,),
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,13 @@ class DeviceFilterSet(
|
||||
lookup_expr='in',
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='location',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -1739,6 +1746,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceModeChoices,
|
||||
label=_('802.1Q Mode')
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, VRF
|
||||
from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||
from netbox.choices import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
@@ -1356,6 +1356,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1427,6 +1428,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
mode = forms.MultipleChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
label=_('802.1Q mode')
|
||||
)
|
||||
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Translation Policy')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
|
||||
@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
# Validate that all replication fields generate an equal number of values (or a single value)
|
||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||
return
|
||||
|
||||
pattern_count = len(patterns)
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
if self.cleaned_data[field_name]:
|
||||
if value_count == 1:
|
||||
# If the field resolves to a single value (because no pattern was used), multiply it by the number
|
||||
# of expected values. This allows us to reuse the same label when creating multiple components.
|
||||
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
|
||||
elif value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
#
|
||||
@@ -404,6 +408,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_chassis_id': 'null',
|
||||
'site_id': '$site',
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
|
||||
@@ -225,8 +225,7 @@ class CableTraceSVG:
|
||||
"""
|
||||
nodes_height = 0
|
||||
nodes = []
|
||||
# Sort them by name to make renders more readable
|
||||
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
|
||||
for i, term in enumerate(terminations):
|
||||
node = Node(
|
||||
position=(offset_x + i * width, self.cursor),
|
||||
width=width,
|
||||
|
||||
@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
|
||||
|
||||
INTERFACE_FHRPGROUPS = """
|
||||
{% for assignment in value.all %}
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -2801,6 +2801,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rack(self):
|
||||
racks = Rack.objects.all()[:2]
|
||||
@@ -4416,7 +4418,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_mode(self):
|
||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
||||
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
@@ -236,7 +236,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
regions,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
@@ -246,8 +246,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
).distinct(),
|
||||
'region_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Region),
|
||||
scope_id__in=regions
|
||||
).distinct(),
|
||||
'region'
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
|
||||
'region_id'
|
||||
),
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
@@ -330,10 +341,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
groups,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(
|
||||
ASN.objects.restrict(request.user, 'view').filter(
|
||||
sites__group__in=groups
|
||||
).distinct(),
|
||||
'site_group_id'),
|
||||
(
|
||||
VirtualMachine.objects.restrict(request.user, 'view').filter(
|
||||
site__group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(SiteGroup),
|
||||
scope_id__in=groups
|
||||
).distinct(),
|
||||
'site_group'
|
||||
),
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
terminations___site_group=instance
|
||||
@@ -342,6 +372,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
@@ -444,6 +478,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -523,7 +558,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
locations,
|
||||
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
|
||||
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
@@ -533,6 +568,10 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
|
||||
'location_id'
|
||||
),
|
||||
(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'),
|
||||
@@ -793,7 +832,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
])
|
||||
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(CableTermination,),
|
||||
extra=(
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Rack),
|
||||
scope_id=instance.pk
|
||||
), 'rack'),
|
||||
),
|
||||
),
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
|
||||
@@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = (
|
||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||
)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
|
||||
@@ -566,28 +566,23 @@ class BaseScript:
|
||||
def load_yaml(self, filename):
|
||||
"""
|
||||
Return data from a YAML file
|
||||
TODO: DEPRECATED: Remove this method in v4.4
|
||||
"""
|
||||
# TODO: DEPRECATED: Remove this method in v4.4
|
||||
self._log(
|
||||
_("load_yaml is deprecated and will be removed in v4.4"),
|
||||
level=LogLevelChoices.LOG_WARNING
|
||||
)
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
with open(file_path, 'r') as datafile:
|
||||
data = yaml.load(datafile, Loader=Loader)
|
||||
data = yaml.load(datafile, Loader=yaml.SafeLoader)
|
||||
|
||||
return data
|
||||
|
||||
def load_json(self, filename):
|
||||
"""
|
||||
Return data from a JSON file
|
||||
TODO: DEPRECATED: Remove this method in v4.4
|
||||
"""
|
||||
# TODO: DEPRECATED: Remove this method in v4.4
|
||||
self._log(
|
||||
_("load_json is deprecated and will be removed in v4.4"),
|
||||
level=LogLevelChoices.LOG_WARNING
|
||||
|
||||
@@ -351,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan__group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name="id",
|
||||
label=_('VLAN Group (ID)'),
|
||||
)
|
||||
vlan_group = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan__group__slug',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name="slug",
|
||||
label=_('VLAN Group (slug)'),
|
||||
)
|
||||
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VLAN.objects.all(),
|
||||
label=_('VLAN (ID)'),
|
||||
@@ -1150,7 +1162,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='name',
|
||||
|
||||
@@ -176,7 +176,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
|
||||
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
||||
name=_('Addressing')
|
||||
),
|
||||
FieldSet('vlan_id', name=_('VLAN Assignment')),
|
||||
FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
|
||||
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')),
|
||||
@@ -260,6 +260,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
vlan_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Group'),
|
||||
)
|
||||
vlan_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -538,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
|
||||
@@ -645,9 +645,16 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
vrfs[1].export_targets.add(route_targets[1])
|
||||
vrfs[2].export_targets.add(route_targets[2])
|
||||
|
||||
vlan_groups = (
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
|
||||
)
|
||||
for vlan_group in vlan_groups:
|
||||
vlan_group.save()
|
||||
|
||||
vlans = (
|
||||
VLAN(vid=1, name='VLAN 1'),
|
||||
VLAN(vid=2, name='VLAN 2'),
|
||||
VLAN(vid=1, name='VLAN 1', group=vlan_groups[0]),
|
||||
VLAN(vid=2, name='VLAN 2', group=vlan_groups[1]),
|
||||
VLAN(vid=3, name='VLAN 3'),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
@@ -850,6 +857,13 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vlan_group(self):
|
||||
vlan_groups = VLANGroup.objects.all()[:2]
|
||||
params = {'vlan_group_id': [vlan_groups[0].pk, vlan_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vlan(self):
|
||||
vlans = VLAN.objects.all()[:2]
|
||||
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
||||
|
||||
@@ -47,7 +47,12 @@ class CoreMiddleware:
|
||||
# Check if language cookie should be renewed
|
||||
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
@@ -301,6 +301,14 @@ class CustomFieldsMixin(models.Model):
|
||||
if cf.required and cf.name not in self.custom_field_data:
|
||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Populate default values if omitted
|
||||
for cf in self.custom_fields.filter(default__isnull=False):
|
||||
if cf.name not in self.custom_field_data:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomLinksMixin(models.Model):
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,7 @@ const options = {
|
||||
outdir: './dist',
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: 'external',
|
||||
sourcesContent: false,
|
||||
sourcemap: 'linked',
|
||||
logLevel: 'error',
|
||||
};
|
||||
|
||||
|
||||
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
3
netbox/project-static/dist/netbox.js
vendored
3
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
3
netbox/project-static/dist/netbox.js.map
vendored
3
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
||||
"gridstack": "12.0.0",
|
||||
"htmx.org": "2.0.4",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.86.3",
|
||||
"sass": "1.87.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -5,11 +5,13 @@ interface PluginConfig {
|
||||
export function getPlugins(element: HTMLSelectElement): object {
|
||||
const plugins: PluginConfig = {};
|
||||
|
||||
// Enable "clear all" button
|
||||
plugins.clear_button = {
|
||||
html: (data: Dict) =>
|
||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||
};
|
||||
// Enable "clear all" button for non-required fields
|
||||
if (!element.required) {
|
||||
plugins.clear_button = {
|
||||
html: (data: Dict) =>
|
||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||
};
|
||||
}
|
||||
|
||||
// Enable individual "remove" buttons for items on multi-select fields
|
||||
if (element.hasAttribute('multiple')) {
|
||||
|
||||
@@ -3,6 +3,12 @@ html {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
// Remove horizontal padding from highlighted text
|
||||
mark {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
// Prevent dropdown menus from being clipped inside responsive tables
|
||||
.table-responsive {
|
||||
.dropdown, .btn-group, .btn-group-vertical {
|
||||
|
||||
@@ -2678,10 +2678,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.86.3:
|
||||
version "1.86.3"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.3.tgz#0a0d9ea97cb6665e73f409639f8533ce057464c9"
|
||||
integrity sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==
|
||||
sass@1.87.0:
|
||||
version "1.87.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e"
|
||||
integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block form %}
|
||||
{% include 'dcim/htmx/cable_edit.html' %}
|
||||
{% include 'dcim/htmx/cable_edit.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% render_errors form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{# A side termination #}
|
||||
<div class="field-group mb-5">
|
||||
|
||||
@@ -20,10 +20,15 @@
|
||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{term.device|linkify}}
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
{{ term|linkify }}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{{ term.device|linkify }}
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
{{ term|linkify }}
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -41,7 +46,13 @@
|
||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{ term|linkify }}{% if not forloop.last %},{% endif %}
|
||||
{{ term|linkify }}
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -55,7 +66,13 @@
|
||||
<th scope="row">{% trans "Circuit" %}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
|
||||
{{ term.circuit|linkify }} ({{ term }})
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
{% block content %}
|
||||
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
||||
{% render_errors vc_form %}
|
||||
{% for form in formset %}
|
||||
{% render_errors form %}
|
||||
{% endfor %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% for field in vc_form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{{ pk_form.pk }}
|
||||
{{ formset.management_form }}
|
||||
<div class="field-group my-5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}"{% if widget.required %} required{% endif %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
|
||||
@@ -54,11 +54,14 @@
|
||||
<div class="card">
|
||||
<h2 class="card-header d-flex justify-content-between">
|
||||
{% trans "Rendered Config" %}
|
||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
<div>
|
||||
{% copy_content "rendered_config" %}
|
||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
</div>
|
||||
</h2>
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "VLAN" %}</h2>
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ from urllib.parse import urlencode
|
||||
|
||||
from django.http import QueryDict
|
||||
from django.utils.datastructures import MultiValueDict
|
||||
from netbox.models import CloningMixin
|
||||
|
||||
__all__ = (
|
||||
'dict_to_querydict',
|
||||
@@ -46,7 +47,7 @@ def prepare_cloned_fields(instance):
|
||||
Generate a QueryDict comprising attributes from an object's clone() method.
|
||||
"""
|
||||
# Generate the clone attributes from the instance
|
||||
if not hasattr(instance, 'clone'):
|
||||
if not issubclass(type(instance), CloningMixin):
|
||||
return QueryDict(mutable=True)
|
||||
attrs = instance.clone()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
|
||||
__all__ = (
|
||||
'enum_key',
|
||||
'remove_linebreaks',
|
||||
'title',
|
||||
'trailing_slash',
|
||||
)
|
||||
@@ -15,6 +16,13 @@ def enum_key(value):
|
||||
return re.sub(r'[^_A-Z0-9]', '_', value)
|
||||
|
||||
|
||||
def remove_linebreaks(value):
|
||||
"""
|
||||
Remove all line breaks from a string and return the result. Useful for log sanitization purposes.
|
||||
"""
|
||||
return value.replace('\n', '').replace('\r', '')
|
||||
|
||||
|
||||
def title(value):
|
||||
"""
|
||||
Improved implementation of str.title(); retains all existing uppercase letters.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import VRF
|
||||
from ipam.models import VRF, VLANTranslationPolicy
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
|
||||
@@ -200,7 +201,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
|
||||
FieldSet('enabled', 'mac_address', 'vrf_id', 'l2vpn_id', name=_('Attributes')),
|
||||
FieldSet('enabled', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')),
|
||||
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
||||
cluster_id = DynamicModelMultipleChoiceField(
|
||||
@@ -237,6 +240,16 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('L2VPN')
|
||||
)
|
||||
mode = forms.MultipleChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
label=_('802.1Q mode')
|
||||
)
|
||||
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Translation Policy')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -606,6 +606,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
mtu=100,
|
||||
vrf=vrfs[0],
|
||||
description='foobar1',
|
||||
mode=InterfaceModeChoices.MODE_ACCESS,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
),
|
||||
VMInterface(
|
||||
@@ -615,6 +616,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
mtu=200,
|
||||
vrf=vrfs[1],
|
||||
description='foobar2',
|
||||
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
),
|
||||
VMInterface(
|
||||
@@ -700,6 +702,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mode(self):
|
||||
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_vlan(self):
|
||||
vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
|
||||
params = {'vlan_id': vlan.pk}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch, Sum
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -10,7 +11,7 @@ from dcim.forms import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from dcim.tables import DeviceTable
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
@@ -102,7 +103,17 @@ class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(ClusterGroup),
|
||||
scope_id=instance.pk
|
||||
), 'cluster_group'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -162,15 +173,28 @@ class ClusterListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Cluster)
|
||||
class ClusterView(generic.ObjectView):
|
||||
class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Cluster.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return instance.virtual_machines.aggregate(
|
||||
vcpus_sum=Sum('vcpus'),
|
||||
memory_sum=Sum('memory'),
|
||||
disk_sum=Sum('disk')
|
||||
)
|
||||
return {
|
||||
**instance.virtual_machines.aggregate(
|
||||
vcpus_sum=Sum('vcpus'),
|
||||
memory_sum=Sum('memory'),
|
||||
disk_sum=Sum('disk')
|
||||
),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(),
|
||||
extra=(
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Cluster),
|
||||
scope_id=instance.pk
|
||||
), 'cluster'),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
|
||||
|
||||
Reference in New Issue
Block a user