@@ -88,6 +89,7 @@
{% render_field form.data_source %}
{% render_field form.data_file %}
{% render_field form.format %}
+ {% render_field form.csv_delimiter %}
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 39c86d80e8..71a4961c3a 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -3,7 +3,7 @@
from circuits.models import Circuit
from dcim.models import Device, Rack, Site
from ipam.models import IPAddress, Prefix, VLAN, VRF
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from tenancy import filtersets
from tenancy.models import *
from utilities.utils import count_related
@@ -23,7 +23,7 @@ def get_view_name(self):
# Tenants
#
-class TenantGroupViewSet(NetBoxModelViewSet):
+class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet):
# Contacts
#
-class ContactGroupViewSet(NetBoxModelViewSet):
+class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
Contact,
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
index 5fe84ad5f2..1c3233f872 100644
--- a/netbox/users/forms/model_forms.py
+++ b/netbox/users/forms/model_forms.py
@@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
help_text=_(
'Keys must be at least 40 characters in length. Be sure to record your key prior to '
'submitting this form, as it may no longer be accessible once the token has been created.'
+ ),
+ widget=forms.TextInput(
+ attrs={'data-clipboard': 'true'}
)
)
allowed_ips = SimpleArrayField(
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index 3b418715a4..afb270568d 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -52,7 +52,7 @@ class Meta(NetBoxTable.Meta):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
- 'is_superuser',
+ 'is_superuser', 'last_login',
)
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py
index 6c597b943e..0ee2606db7 100644
--- a/netbox/utilities/counters.py
+++ b/netbox/utilities/counters.py
@@ -62,7 +62,7 @@ def post_save_receiver(sender, instance, created, **kwargs):
update_counter(parent_model, new_pk, counter_name, 1)
-def post_delete_receiver(sender, instance, **kwargs):
+def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
@@ -72,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
# Decrement the parent's counter by one
if parent_pk is not None:
- update_counter(parent_model, parent_pk, counter_name, -1)
+ # MPTT sends two delete signals for child elements so guard against multiple decrements
+ if not origin or origin == instance:
+ update_counter(parent_model, parent_pk, counter_name, -1)
#
diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html
index 379dcc0212..e5a564a3de 100644
--- a/netbox/utilities/templates/form_helpers/render_field.html
+++ b/netbox/utilities/templates/form_helpers/render_field.html
@@ -29,6 +29,14 @@
{{ label }}
+ {# Include a copy-to-clipboard button #}
+ {% elif 'data-clipboard' in field.field.widget.attrs %}
+
+ {{ field }}
+
+
{# Default field rendering #}
{% else %}
{{ field }}
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index 35aec1000f..68541ae5a4 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -1,6 +1,7 @@
from django import template
from django.http import QueryDict
+from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
__all__ = (
@@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
+ if value:
+ if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+ value = customfield.get_choice_label(value)
+ elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+ value = [customfield.get_choice_label(v) for v in value]
return {
'customfield': customfield,
'value': value,
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index cf8850c523..014c758e9a 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -1,7 +1,11 @@
-from django.test import TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
+from django.urls import reverse
from dcim.models import *
-from utilities.testing.utils import create_test_device
+from users.models import ObjectPermission
+from utilities.testing.base import TestCase
+from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase):
@@ -10,7 +14,6 @@ class CountersTest(TestCase):
"""
@classmethod
def setUpTestData(cls):
-
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
@@ -79,3 +82,25 @@ def test_interface_count_move(self):
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_mptt_child_delete(self):
+ device1, device2 = Device.objects.all()
+ inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
+ inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 2)
+
+ # Setup bulk_delete for the inventory items
+ self.add_permissions('dcim.delete_inventoryitem')
+ pk_list = device1.inventoryitems.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Try POST with model-level permission
+ self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 0)
diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py
index 1103cec371..a6cc9f5357 100644
--- a/netbox/wireless/api/views.py
+++ b/netbox/wireless/api/views.py
@@ -1,6 +1,6 @@
from rest_framework.routers import APIRootView
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from wireless import filtersets
from wireless.models import *
from . import serializers
@@ -14,7 +14,7 @@ def get_view_name(self):
return 'Wireless'
-class WirelessLANGroupViewSet(NetBoxModelViewSet):
+class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 0469185353..e8e48eef87 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -2,7 +2,6 @@
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
diff --git a/requirements.txt b/requirements.txt
index 8c676df81b..9f9176ea21 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,34 +1,35 @@
-bleach==6.0.0
-Django==4.2.5
-django-cors-headers==4.2.0
+bleach==6.1.0
+Django==4.2.6
+django-cors-headers==4.3.0
django-debug-toolbar==4.2.0
django-filter==23.3
django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.14
+django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
-django-redis==5.3.0
-django-rich==1.7.0
+django-redis==5.4.0
+django-rich==1.8.0
django-rq==2.8.1
django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==6.0.1
djangorestframework==3.14.0
-drf-spectacular==0.26.4
-drf-spectacular-sidecar==2023.9.1
+drf-spectacular==0.26.5
+drf-spectacular-sidecar==2023.10.1
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.4.2
+mkdocs-material==9.4.6
mkdocstrings[python-legacy]==0.23.0
netaddr==0.9.0
-Pillow==10.0.1
-psycopg[binary,pool]==3.1.11
+Pillow==10.1.0
+psycopg[binary,pool]==3.1.12
PyYAML==6.0.1
-sentry-sdk==1.31.0
-social-auth-app-django==5.3.0
+requests==2.31.0
+sentry-sdk==1.32.0
+social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
tablib==3.5.0