Compare commits

..

25 Commits

Author SHA1 Message Date
Arthur
be32bc8a74 fix ordering 2026-02-04 13:38:52 -08:00
Arthur
d18ef98b00 fix dropdown sorting 2026-02-04 11:57:23 -08:00
Arthur
8aab80b56f merge main 2026-02-04 11:44:22 -08:00
Arthur
905d17294a Review feedback 2026-01-28 17:27:01 -08:00
Arthur
44e5a4c177 Review feedback 2026-01-28 17:12:13 -08:00
Arthur
de19447317 Merge branch 'main' into 20911-dropdown 2026-01-23 15:59:08 -08:00
Arthur
f195af206b fix csv import 2026-01-23 15:46:26 -08:00
Arthur
b0ac55ed6a cleanup 2026-01-21 16:44:48 -08:00
Arthur
91ab818411 use bulk_update and rebuild 2026-01-21 16:23:24 -08:00
Arthur
62b9367ae3 use bulk_update and rebuild 2026-01-21 16:14:14 -08:00
Arthur
0c091aa80e cleanup 2026-01-21 13:18:34 -08:00
Arthur
94836e5a37 fix migration 2026-01-21 12:55:34 -08:00
Arthur
c92912ff03 fix migration 2026-01-21 12:52:41 -08:00
Arthur
ef0bc18095 fix migration 2026-01-21 12:47:16 -08:00
Arthur
99f727e685 fix migration 2026-01-21 12:41:59 -08:00
Arthur
6a5aced4bc fix migration 2026-01-21 12:28:01 -08:00
Arthur
46f9a12a87 add migration 2026-01-21 11:59:03 -08:00
Arthur
be1a008216 rebuild tree after rename 2026-01-20 15:28:49 -08:00
Arthur
c4c3518bb4 change ordering field, remove front-end changes 2026-01-20 13:45:17 -08:00
Arthur
5a1282e326 Merge branch 'main' into 20911-dropdown 2026-01-14 13:39:45 -08:00
Arthur
cb13eb277f use correct node version 2026-01-14 13:36:33 -08:00
Arthur
24642be351 cleanup 2026-01-08 17:08:10 -08:00
Arthur
89af9efd85 cleanup 2026-01-08 17:04:00 -08:00
Arthur
99d678502f cleanup 2026-01-08 16:23:47 -08:00
Arthur
e6300ee06d Fix TomSelect dropdown ordering 2026-01-08 16:17:40 -08:00
32 changed files with 634 additions and 733 deletions

View File

@@ -200,48 +200,6 @@ REDIS = {
!!! note
It is permissible to use Sentinel for only one database and not the other.
### SSL Configuration
If you need to configure SSL/TLS for Redis beyond the basic `SSL`, `CA_CERT_PATH`, and `INSECURE_SKIP_TLS_VERIFY` options (for example, client certificates, a specific TLS version, or custom ciphers), you can pass additional parameters via the `KWARGS` key in either the `tasks` or `caching` subsection.
NetBox already maps `CA_CERT_PATH` to `ssl_ca_certs` and (for caching) `INSECURE_SKIP_TLS_VERIFY` to `ssl_cert_reqs`; only add `KWARGS` when you need to override or extend those settings (for example, to supply client certificates or restrict TLS version or ciphers).
* `KWARGS` - Optional dictionary of additional SSL/TLS (or other) parameters passed to the Redis client. These are passed directly to the underlying Redis client: for `tasks` to [redis-py](https://redis-py.readthedocs.io/en/stable/connections.html), and for `caching` to the [django-redis](https://github.com/jazzband/django-redis#configure-as-cache-backend) connection pool.
Example:
```python
REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'SSL': True,
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
'KWARGS': {
'ssl_certfile': '/path/to/client-cert.pem',
'ssl_keyfile': '/path/to/client-key.pem',
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
'ssl_ciphers': 'HIGH:!aNULL',
},
},
'caching': {
'HOST': 'redis.example.com',
'PORT': 1234,
'SSL': True,
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
'KWARGS': {
'ssl_certfile': '/path/to/client-cert.pem',
'ssl_keyfile': '/path/to/client-key.pem',
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
'ssl_ciphers': 'HIGH:!aNULL',
},
}
}
```
!!! note
If you use `ssl.TLSVersion` in your configuration (e.g. `ssl_min_version`), add `import ssl` at the top of your configuration file.
---
## SECRET_KEY

View File

@@ -1,7 +1,6 @@
import json
import platform
from copy import deepcopy
from django import __version__ as django_version
from django.conf import settings
from django.contrib import messages
@@ -311,22 +310,6 @@ class ConfigRevisionListView(generic.ObjectListView):
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_extra_context(self, request, instance):
"""
Retrieve additional context for a given request and instance.
"""
# Copy the revision data to avoid modifying the original
config = deepcopy(instance.data or {})
# Serialize any JSON-based classes
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
if attr in config:
config[attr] = json.dumps(config[attr], cls=ConfigJSONEncoder, indent=4)
return {
'config': config,
}
@register_model_view(ConfigRevision, 'add', detail=False)
class ConfigRevisionEditView(generic.ObjectEditView):
@@ -634,8 +617,8 @@ class SystemView(UserPassesTestMixin, View):
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
# Serialize any JSON-based classes
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
# Serialize any CustomValidator classes
for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
if hasattr(config, attr) and getattr(config, attr, None):
setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))

View File

@@ -516,7 +516,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class ModuleBayViewSet(NetBoxModelViewSet):
queryset = ModuleBay.objects.all()
queryset = ModuleBay.objects.order_by('device', 'module', 'name')
serializer_class = serializers.ModuleBaySerializer
filterset_class = filtersets.ModuleBayFilterSet

View File

@@ -745,7 +745,8 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
label=_('Module bay'),
queryset=ModuleBay.objects.all(),
query_params={
'device_id': '$device'
'device_id': '$device',
'ordering': 'module,name',
},
context={
'disabled': 'installed_module',

View File

@@ -0,0 +1,32 @@
from django.db import migrations
import mptt.managers
import mptt.models
def rebuild_mptt(apps, schema_editor):
"""
Rebuild the MPTT tree for ModuleBay to apply new ordering.
"""
ModuleBay = apps.get_model('dcim', 'ModuleBay')
# Set MPTTMeta with the correct order_insertion_by
class MPTTMeta:
order_insertion_by = ('module', 'name',)
ModuleBay.MPTTMeta = MPTTMeta
ModuleBay._mptt_meta = mptt.models.MPTTOptions(MPTTMeta)
manager = mptt.managers.TreeManager()
manager.model = ModuleBay
manager.contribute_to_class(ModuleBay, 'objects')
manager.rebuild()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0225_gfk_indexes'),
]
operations = [
migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1273,7 +1273,7 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
verbose_name_plural = _('module bays')
class MPTTMeta:
order_insertion_by = ('module',)
order_insertion_by = ('module', 'name',)
def clean(self):
super().clean()

View File

@@ -5,6 +5,7 @@ from django.db import models
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from mptt.models import MPTTModel
from dcim.choices import *
from dcim.utils import create_port_mappings, update_interface_bridges
@@ -331,7 +332,8 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
component._location = self.device.location
component._rack = self.device.rack
if component_model is not ModuleBay:
# we handle create and update separately - this is for create
if not issubclass(component_model, MPTTModel):
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object
for component in create_instances:
@@ -344,11 +346,13 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=None
)
else:
# ModuleBays must be saved individually for MPTT
# MPTT models must be saved individually to maintain tree structure
for instance in create_instances:
instance.save()
update_fields = ['module']
# we handle create and update separately - this is for update
component_model.objects.bulk_update(update_instances, update_fields)
# Emit the post_save signal for each updated object
for component in update_instances:
@@ -361,7 +365,12 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=update_fields
)
# Rebuild MPTT tree if needed (bulk_update bypasses model save)
if issubclass(component_model, MPTTModel) and update_instances:
component_model.objects.rebuild()
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)

View File

@@ -584,15 +584,6 @@ class BaseInterfaceTable(NetBoxTable):
orderable=False,
verbose_name=_('IP Addresses')
)
primary_mac_address = tables.Column(
verbose_name=_('Primary MAC'),
linkify=True
)
mac_addresses = columns.ManyToManyColumn(
orderable=False,
linkify_item=True,
verbose_name=_('MAC Addresses')
)
fhrp_groups = tables.TemplateColumn(
accessor=Accessor('fhrp_group_assignments'),
template_code=INTERFACE_FHRPGROUPS,
@@ -624,6 +615,10 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)
primary_mac_address = tables.Column(
verbose_name=_('MAC Address'),
linkify=True
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
@@ -686,12 +681,11 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_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', 'tunnel', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'vlan_translation_policy',
'speed', 'speed_formatted', 'duplex', 'mode', 'primary_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', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -752,11 +746,10 @@ class DeviceInterfaceTable(InterfaceTable):
model = models.Interface
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
'mgmt_only', 'mtu', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'actions',
'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
@@ -887,36 +880,24 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
role = columns.ColoredLabelColumn(
accessor=Accessor('installed_device__role'),
verbose_name=_('Role')
)
device_type = tables.Column(
accessor=Accessor('installed_device__device_type'),
linkify=True,
verbose_name=_('Type')
)
status = tables.TemplateColumn(
verbose_name=_('Status'),
template_code=DEVICEBAY_STATUS,
order_by=Accessor('installed_device__status')
)
installed_device = tables.Column(
verbose_name=_('Installed Device'),
verbose_name=_('Installed device'),
linkify=True
)
installed_role = columns.ColoredLabelColumn(
accessor=Accessor('installed_device__role'),
verbose_name=_('Installed Role')
)
installed_device_type = tables.Column(
accessor=Accessor('installed_device__device_type'),
linkify=True,
verbose_name=_('Installed Type')
)
installed_description = tables.Column(
accessor=Accessor('installed_device__description'),
verbose_name=_('Installed Description')
)
installed_serial = tables.Column(
accessor=Accessor('installed_device__serial'),
verbose_name=_('Installed Serial')
)
installed_asset_tag = tables.Column(
accessor=Accessor('installed_device__asset_tag'),
verbose_name=_('Installed Asset Tag')
)
tags = columns.TagColumn(
url_name='dcim:devicebay_list'
)
@@ -924,9 +905,8 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'device', 'label', 'status', 'role', 'device_type', 'installed_device', 'description',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -1219,6 +1199,4 @@ class MACAddressTable(PrimaryModelTable):
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')

View File

@@ -90,6 +90,7 @@ class DevicePanel(panels.ObjectAttributesPanel):
parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
gps_coordinates = attrs.GPSCoordinatesAttr()
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
description = attrs.TextAttr('description')
airflow = attrs.ChoiceAttr('airflow')
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
@@ -121,19 +122,10 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
class DeviceDeviceTypePanel(panels.ObjectAttributesPanel):
title = _('Device Type')
manufacturer = attrs.RelatedObjectAttr('device_type.manufacturer', linkify=True)
model = attrs.RelatedObjectAttr('device_type', linkify=True)
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
front_image = attrs.ImageAttr('device_type.front_image')
rear_image = attrs.ImageAttr('device_type.rear_image')
class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
title = _('Dimensions')
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')

View File

@@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
from dcim.ui import panels
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
@@ -43,7 +44,6 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
from .models.device_components import PortMapping
from .object_actions import BulkAddComponents, BulkDisconnect
from .ui import panels
CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort,
@@ -2470,7 +2470,6 @@ class DeviceView(generic.ObjectView):
],
),
ImageAttachmentsPanel(),
panels.DeviceDeviceTypePanel(),
panels.DeviceDimensionsPanel(),
TemplatePanel('dcim/panels/device_rack_elevations.html'),
],

View File

@@ -1,16 +1,16 @@
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet
from rq import Worker
from extras import filtersets
@@ -264,57 +264,10 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
#
@extend_schema_view(
create=extend_schema(exclude=True), # Hide POST from list endpoint in Swagger
update=extend_schema(
request=serializers.ScriptInputSerializer,
examples=[
OpenApiExample(
'Script with no variables',
value={'data': {}, 'commit': True},
request_only=True,
),
OpenApiExample(
'Script with variables',
value={
'data': {
'variable_name': 'example_value',
'another_variable': 123
},
'commit': True
},
request_only=True,
),
]
),
partial_update=extend_schema(
request=serializers.ScriptInputSerializer,
examples=[
OpenApiExample(
'Script with no variables',
value={'data': {}, 'commit': True},
request_only=True,
),
OpenApiExample(
'Script with variables',
value={
'data': {
'variable_name': 'example_value',
'another_variable': 123
},
'commit': True
},
request_only=True,
),
]
),
update=extend_schema(request=serializers.ScriptInputSerializer),
partial_update=extend_schema(request=serializers.ScriptInputSerializer),
)
class ScriptViewSet(
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
DestroyModelMixin,
GenericViewSet
):
class ScriptViewSet(ModelViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = Script.objects.all()
serializer_class = serializers.ScriptSerializer
@@ -350,32 +303,11 @@ class ScriptViewSet(
return Response(serializer.data)
@extend_schema(
request=serializers.ScriptInputSerializer,
responses={200: serializers.ScriptDetailSerializer},
examples=[
OpenApiExample(
'Script with no variables',
value={'data': {}, 'commit': True},
request_only=True,
),
OpenApiExample(
'Script with variables',
value={
'data': {
'variable_name': 'example_value',
'another_variable': 123
},
'commit': True
},
request_only=True,
),
]
)
def post(self, request, pk):
"""
Run a Script identified by its numeric PK or module & name and return the pending Job as the result
"""
script = self._get_script(pk)
if not request.user.has_perm('extras.run_script', obj=script):

View File

@@ -39,20 +39,9 @@ __all__ = (
)
IMAGEATTACHMENT_IMAGE = """
{% load thumbnail %}
{% if record.image %}
{% thumbnail record.image "400x400" as tn %}
<a href="{{ record.get_absolute_url }}"
class="image-preview"
data-preview-url="{{ tn.url }}"
data-bs-placement="left"
title="{{ record.filename }}"
rel="noopener noreferrer"
target="_blank"
aria-label="{{ record.filename }}">
<i class="mdi mdi-image"></i>
</a>
{% endthumbnail %}
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a>
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
"""

View File

@@ -408,11 +408,6 @@ if CACHING_REDIS_CA_CERT_PATH:
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
# Merge in KWARGS for additional parameters
if caching_redis_kwargs := REDIS['caching'].get('KWARGS'):
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS'].update(caching_redis_kwargs)
#
# Sessions
@@ -778,7 +773,7 @@ SPECTACULAR_SETTINGS = {
'COMPONENT_SPLIT_REQUEST': True,
'REDOC_DIST': 'SIDECAR',
'SERVERS': [{
'url': '',
'url': BASE_PATH,
'description': 'NetBox',
}],
'SWAGGER_UI_DIST': 'SIDECAR',
@@ -822,11 +817,6 @@ if TASKS_REDIS_CA_CERT_PATH:
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
# Merge in KWARGS for additional parameters
if tasks_redis_kwargs := TASKS_REDIS.get('KWARGS'):
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
RQ_PARAMS['REDIS_CLIENT_KWARGS'].update(tasks_redis_kwargs)
# Define named RQ queues
RQ_QUEUES = {
RQ_QUEUE_HIGH: RQ_PARAMS,

View File

@@ -437,30 +437,12 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
"""
return object_form.save()
def create_and_update_objects(self, form, request):
def _process_import_records(self, form, request, records, prefetched_objects):
"""
Process CSV import records and save objects.
"""
saved_objects = []
records = list(form.cleaned_data['data'])
# Prefetch objects to be updated, if any
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
# check for duplicate IDs
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
if duplicate_pks:
error_msg = _(
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
).format(
model=title(self.queryset.model._meta.verbose_name),
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
)
raise ValidationError(error_msg)
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
for i, record in enumerate(records, start=1):
object_id = int(record.pop('id')) if record.get('id') else None
@@ -524,6 +506,37 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
return saved_objects
def create_and_update_objects(self, form, request):
records = list(form.cleaned_data['data'])
# Prefetch objects to be updated, if any
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
# check for duplicate IDs
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
if duplicate_pks:
error_msg = _(
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
).format(
model=title(self.queryset.model._meta.verbose_name),
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
)
raise ValidationError(error_msg)
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
# For MPTT models, delay tree updates until all saves are complete
if issubclass(self.queryset.model, MPTTModel):
with self.queryset.model.objects.delay_mptt_updates():
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
else:
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
return saved_objects
#
# Request handlers
#
@@ -893,9 +906,16 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = self._rename_objects(form, selected_objects)
if '_apply' in request.POST:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
# For MPTT models, delay tree updates until all saves are complete
if issubclass(self.queryset.model, MPTTModel):
with self.queryset.model.objects.delay_mptt_updates():
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
else:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
# Enforce constrained permissions
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -150,22 +150,20 @@ function initSidebarAccordions(): void {
*/
function initImagePreview(): void {
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
// Prefer a thumbnail URL for the popover (so we don't preload full-size images),
// but fall back to the link target if no thumbnail was provided.
const previewUrl = element.dataset.previewUrl ?? element.href;
const image = createElement('img', { src: previewUrl });
// Generate a max-width that's a quarter of the screen's width (note - the actual element
// width will be slightly larger due to the popover body's padding).
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
// Ensure lazy loading and async decoding
image.loading = 'lazy';
image.decoding = 'async';
// Create an image element that uses the linked image as its `src`.
const image = createElement('img', { src: element.href });
image.style.maxWidth = maxWidth;
// Create a container for the image.
const content = createElement('div', null, null, [image]);
// Initialize the Bootstrap Popper instance.
new Popover(element, {
// Attach this custom class to the popover so that its styling
// can be controlled via CSS.
// Attach this custom class to the popover so that it styling can be controlled via CSS.
customClass: 'image-preview-popover',
trigger: 'hover',
html: true,

View File

@@ -89,29 +89,6 @@ img.plugin-icon {
}
}
// Image preview popover (rendered for <a.image-preview> by initImagePreview())
.image-preview-popover {
--bs-popover-max-width: clamp(240px, 25vw, 640px);
.popover-header {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popover-body {
display: flex;
justify-content: center;
align-items: center;
}
img {
display: block;
max-width: 100%;
max-height: clamp(160px, 33vh, 640px);
height: auto;
}
}
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {

View File

@@ -5,16 +5,6 @@
font-variant-ligatures: none;
}
// TODO: Remove when Tabler releases fix for https://github.com/tabler/tabler/issues/2271
// and NetBox upgrades to that version. Fix merged to Tabler dev branch in PR #2548.
:root,
:host {
@include media-breakpoint-up(lg) {
margin-left: 0;
scrollbar-gutter: stable;
}
}
// Restore default foreground & background colors for <pre> blocks
pre {
background-color: transparent;

View File

@@ -33,7 +33,7 @@
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Configuration Data" %}</h2>
{% include 'core/inc/config_data.html' %}
{% include 'core/inc/config_data.html' with config=object.data %}
</div>
<div class="card">

View File

@@ -95,7 +95,7 @@
<tr>
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
{% if config.CUSTOM_VALIDATORS %}
<td><pre class="p-0">{{ config.CUSTOM_VALIDATORS }}</pre></td>
<td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
@@ -103,7 +103,7 @@
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
{% if config.PROTECTION_RULES %}
<td class="border-0"><pre class="p-0">{{ config.PROTECTION_RULES }}</pre></td>
<td class="border-0"><pre>{{ config.PROTECTION_RULES }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}
@@ -116,7 +116,7 @@
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
{% if config.DEFAULT_USER_PREFERENCES %}
<td class="border-0"><pre class="p-0">{{ config.DEFAULT_USER_PREFERENCES }}</pre></td>
<td class="border-0"><pre>{{ config.DEFAULT_USER_PREFERENCES|json }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}

View File

@@ -1,34 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "Resources" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
<td>{{ object.vcpus|placeholder }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
</th>
<td>
{% if object.disk %}
{{ object.disk|humanize_disk_megabytes }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>

View File

@@ -1 +1,199 @@
{% extends 'virtualization/virtualmachine/base.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Machine" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Start on boot" %}</th>
<td>{% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td>{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv4" %}</th>
<td>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv6" %}</th>
<td>
{% if object.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Cluster" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{{ object.site|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cluster" %}</th>
<td>
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
{{ object.cluster|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cluster Type" %}</th>
<td>
{{ object.cluster.type|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>
{{ object.device|linkify|placeholder }}
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Resources" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
<td>{{ object.vcpus|placeholder }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
</th>
<td>
{% if object.disk %}
{{ object.disk|humanize_disk_megabytes }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">
{% trans "Application Services" %}
{% if perms.ipam.add_service %}
<div class="card-actions">
<a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an application service" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'ipam:service_list' virtual_machine_id=object.pk %}
</div>
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Virtual Disks" %}
{% if perms.virtualization.add_virtualdisk %}
<div class="card-actions">
<a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'virtualization:virtualdisk_list' virtual_machine_id=object.pk %}
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,10 +0,0 @@
{% load i18n %}
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
{% if value.nat_inside %}
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
{% elif value.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>

View File

@@ -78,8 +78,8 @@
<tr>
<th scope="row">{% trans "MAC Address" %}</th>
<td>
{% if object.primary_mac_address %}
<span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
{% if object.mac_address %}
<span class="font-monospace">{{ object.mac_address }}</span>
<span class="badge text-bg-primary">{% trans "Primary" %}</span>
{% else %}
{{ ''|placeholder }}

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,6 @@ class TokenTable(NetBoxTable):
token = columns.TemplateColumn(
verbose_name=_('token'),
template_code=TOKEN,
orderable=False,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled')

View File

@@ -1,24 +0,0 @@
from django.test import RequestFactory, tag, TestCase
from users.models import Token
from users.tables import TokenTable
class TokenTableTest(TestCase):
@tag('regression')
def test_every_orderable_field_does_not_throw_exception(self):
tokens = Token.objects.all()
disallowed = {'actions'}
orderable_columns = [
column.name for column in TokenTable(tokens).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for direction in ('-', ''):
with self.subTest(col=col, direction=direction):
table = TokenTable(tokens)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)

View File

@@ -1,34 +0,0 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class VirtualMachinePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
start_on_boot = attrs.ChoiceAttr('start_on_boot')
role = attrs.RelatedObjectAttr('role', linkify=True)
platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
description = attrs.TextAttr('description')
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
primary_ip4 = attrs.TemplatedAttr(
'primary_ip4',
label=_('Primary IPv4'),
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
)
primary_ip6 = attrs.TemplatedAttr(
'primary_ip6',
label=_('Primary IPv6'),
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
)
class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
title = _('Cluster')
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
device = attrs.RelatedObjectAttr('device', linkify=True)

View File

@@ -10,15 +10,12 @@ from dcim.filtersets import DeviceFilterSet
from dcim.forms import DeviceFilterForm
from dcim.models import Device
from dcim.tables import DeviceTable
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import (
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename, DeleteObject, EditObject,
)
from netbox.ui import actions, layout
from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
from netbox.views import generic
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
@@ -26,7 +23,6 @@ from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables
from .models import *
from .object_actions import BulkAddComponents
from .ui import panels
#
@@ -340,7 +336,6 @@ class ClusterAddDevicesView(generic.ObjectEditView):
# Virtual machines
#
@register_model_view(VirtualMachine, 'list', path='', detail=False)
class VirtualMachineListView(generic.ObjectListView):
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
@@ -353,44 +348,6 @@ class VirtualMachineListView(generic.ObjectListView):
@register_model_view(VirtualMachine)
class VirtualMachineView(generic.ObjectView):
queryset = VirtualMachine.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualMachinePanel(),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.VirtualMachineClusterPanel(),
TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
ObjectsTablePanel(
model='ipam.Service',
title=_('Application Services'),
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'ipam.Service',
url_params={
'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'parent': lambda ctx: ctx['object'].pk,
},
),
],
),
ImageAttachmentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='virtualization.VirtualDisk',
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'virtualization.VirtualDisk', url_params={'virtual_machine': lambda ctx: ctx['object'].pk}
),
],
),
],
)
@register_model_view(VirtualMachine, 'interfaces')