Просмотр исходного кода

Merge branch 'feature' into 15278-primary-nested-serializers

Jeremy Stretch 1 год назад
Родитель
Сommit
cd74e040c1
53 измененных файлов с 267 добавлено и 233 удалено
  1. 1 1
      docs/customization/custom-scripts.md
  2. 2 2
      docs/models/dcim/device.md
  3. 8 0
      docs/release-notes/version-4.0.md
  4. 14 22
      netbox/dcim/api/serializers_/devices.py
  5. 0 14
      netbox/dcim/models/devices.py
  6. 1 1
      netbox/dcim/tests/test_cablepaths.py
  7. 0 24
      netbox/dcim/tests/test_models.py
  8. 2 2
      netbox/netbox/authentication.py
  9. 3 3
      netbox/netbox/navigation/menu.py
  10. 1 2
      netbox/netbox/tests/test_authentication.py
  11. 0 0
      netbox/project-static/dist/netbox.css
  12. 0 0
      netbox/project-static/dist/netbox.js
  13. 0 0
      netbox/project-static/dist/netbox.js.map
  14. BIN
      netbox/project-static/img/tint_20.png
  15. 6 5
      netbox/project-static/src/objectSelector.ts
  16. 1 1
      netbox/project-static/src/select/dynamic.ts
  17. 2 2
      netbox/project-static/src/select/static.ts
  18. 0 23
      netbox/project-static/src/util.ts
  19. 7 0
      netbox/project-static/styles/custom/_markdown.scss
  20. 1 1
      netbox/project-static/styles/custom/_misc.scss
  21. 4 0
      netbox/project-static/styles/overrides/_tabler.scss
  22. 0 1
      netbox/project-static/styles/transitional/_tables.scss
  23. 0 5
      netbox/templates/base/base.html
  24. 1 1
      netbox/templates/dcim/rack.html
  25. 5 7
      netbox/templates/dcim/rack_elevation_list.html
  26. 1 1
      netbox/templates/inc/toast.html
  27. 1 1
      netbox/templates/users/group.html
  28. 1 1
      netbox/templates/users/objectpermission.html
  29. 1 1
      netbox/templates/users/user.html
  30. 1 2
      netbox/users/api/nested_serializers.py
  31. 1 2
      netbox/users/api/serializers_/permissions.py
  32. 1 1
      netbox/users/api/serializers_/users.py
  33. 4 6
      netbox/users/api/views.py
  34. 1 2
      netbox/users/filtersets.py
  35. 1 1
      netbox/users/forms/bulk_import.py
  36. 2 3
      netbox/users/forms/filtersets.py
  37. 3 4
      netbox/users/forms/model_forms.py
  38. 3 3
      netbox/users/graphql/schema.py
  39. 1 1
      netbox/users/graphql/types.py
  40. 16 4
      netbox/users/migrations/0005_alter_user_table.py
  41. 80 0
      netbox/users/migrations/0006_custom_group_model.py
  42. 56 28
      netbox/users/models.py
  43. 4 4
      netbox/users/tables.py
  44. 1 2
      netbox/users/tests/test_api.py
  45. 1 2
      netbox/users/tests/test_filtersets.py
  46. 1 2
      netbox/users/tests/test_views.py
  47. 5 5
      netbox/users/urls.py
  48. 10 10
      netbox/users/views.py
  49. 3 13
      netbox/utilities/api.py
  50. 1 1
      netbox/utilities/templates/form_helpers/render_field.html
  51. 1 1
      netbox/utilities/templates/navigation/menu.html
  52. 3 1
      netbox/utilities/templates/widgets/number_with_options.html
  53. 4 14
      netbox/utilities/utils.py

+ 1 - 1
docs/customization/custom-scripts.md

@@ -476,7 +476,7 @@ class NewBranchScript(Script):
                 name=f'{site.slug}-switch{i}',
                 name=f'{site.slug}-switch{i}',
                 site=site,
                 site=site,
                 status=DeviceStatusChoices.STATUS_PLANNED,
                 status=DeviceStatusChoices.STATUS_PLANNED,
-                device_role=switch_role
+                role=switch_role
             )
             )
             switch.full_clean()
             switch.full_clean()
             switch.save()
             switch.save()

+ 2 - 2
docs/models/dcim/device.md

@@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
 
 
 The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
 The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
 
 
-### Device Role
+### Role
 
 
-The functional [role](./devicerole.md) assigned to this device.
+The functional [device role](./devicerole.md) assigned to this device.
 
 
 ### Device Type
 ### Device Type
 
 

+ 8 - 0
docs/release-notes/version-4.0.md

@@ -13,6 +13,10 @@
 
 
 The NetBox user interface has been completely refreshed and updated.
 The NetBox user interface has been completely refreshed and updated.
 
 
+#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
+
+The REST API now supports specifying which fields to include in the response data.
+
 ### Enhancements
 ### Enhancements
 
 
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
@@ -22,6 +26,9 @@ The NetBox user interface has been completely refreshed and updated.
 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
 * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
 * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
+* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
+* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
+* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
 
 
 ### Other Changes
 ### Other Changes
 
 
@@ -34,5 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
 * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
 * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
 * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
 * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
 * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
 * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
+* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
 * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
 * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class
 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

+ 14 - 22
netbox/dcim/api/serializers_/devices.py

@@ -32,11 +32,6 @@ class DeviceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = DeviceTypeSerializer(nested=True)
     device_type = DeviceTypeSerializer(nested=True)
     role = DeviceRoleSerializer(nested=True)
     role = DeviceRoleSerializer(nested=True)
-    device_role = DeviceRoleSerializer(
-        nested=True,
-        read_only=True,
-        help_text='Deprecated in v3.6 in favor of `role`.'
-    )
     tenant = TenantSerializer(
     tenant = TenantSerializer(
         nested=True,
         nested=True,
         required=False,
         required=False,
@@ -83,13 +78,13 @@ class DeviceSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
-            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
-            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
-            'device_bay_count', 'module_bay_count', 'inventory_item_count',
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
+            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
+            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
+            'module_bay_count', 'inventory_item_count',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
@@ -104,22 +99,19 @@ class DeviceSerializer(NetBoxModelSerializer):
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         return data
         return data
 
 
-    def get_device_role(self, obj):
-        return obj.role
-
 
 
 class DeviceWithConfigContextSerializer(DeviceSerializer):
 class DeviceWithConfigContextSerializer(DeviceSerializer):
     config_context = serializers.SerializerMethodField(read_only=True)
     config_context = serializers.SerializerMethodField(read_only=True)
 
 
     class Meta(DeviceSerializer.Meta):
     class Meta(DeviceSerializer.Meta):
         fields = [
         fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
-            'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
-            'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
-            'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
+            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count',
         ]
         ]
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))

+ 0 - 14
netbox/dcim/models/devices.py

@@ -815,20 +815,6 @@ class Device(
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
         return reverse('dcim:device', args=[self.pk])
 
 
-    @property
-    def device_role(self):
-        """
-        For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
-        """
-        return self.role
-
-    @device_role.setter
-    def device_role(self, value):
-        """
-        For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
-        """
-        self.role = value
-
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

+ 1 - 1
netbox/dcim/tests/test_cablepaths.py

@@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
         device = Device.objects.create(
         device = Device.objects.create(
             site=self.site,
             site=self.site,
             device_type=self.device.device_type,
             device_type=self.device.device_type,
-            device_role=self.device.device_role,
+            role=self.device.role,
             name='Test mid-span Device'
             name='Test mid-span Device'
         )
         )
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')

+ 0 - 24
netbox/dcim/tests/test_models.py

@@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
         device2.full_clean()
         device2.full_clean()
         device2.save()
         device2.save()
 
 
-    def test_old_device_role_field(self):
-        """
-        Ensure that the old device role field sets the value in the new role field.
-        """
-
-        # Test getter method
-        device = Device(
-            site=Site.objects.first(),
-            device_type=DeviceType.objects.first(),
-            role=DeviceRole.objects.first(),
-            name='Test Device 1',
-            device_role=DeviceRole.objects.first()
-        )
-        device.full_clean()
-        device.save()
-
-        self.assertEqual(device.role, device.device_role)
-
-        # Test setter method
-        device.device_role = DeviceRole.objects.last()
-        device.full_clean()
-        device.save()
-        self.assertEqual(device.role, device.device_role)
-
 
 
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
 
 

+ 2 - 2
netbox/netbox/authentication.py

@@ -4,13 +4,13 @@ from collections import defaultdict
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
 from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
-from django.contrib.auth.models import Group, AnonymousUser
+from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.constants import CONSTRAINT_TOKEN_USER
-from users.models import ObjectPermission
+from users.models import Group, ObjectPermission
 from utilities.permissions import (
 from utilities.permissions import (
     permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
     permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
 )
 )

+ 3 - 3
netbox/netbox/navigation/menu.py

@@ -392,19 +392,19 @@ ADMIN_MENU = Menu(
                 ),
                 ),
                 # Proxy model for auth.Group
                 # Proxy model for auth.Group
                 MenuItem(
                 MenuItem(
-                    link=f'users:netboxgroup_list',
+                    link=f'users:group_list',
                     link_text=_('Groups'),
                     link_text=_('Groups'),
                     permissions=[f'auth.view_group'],
                     permissions=[f'auth.view_group'],
                     staff_only=True,
                     staff_only=True,
                     buttons=(
                     buttons=(
                         MenuItemButton(
                         MenuItemButton(
-                            link=f'users:netboxgroup_add',
+                            link=f'users:group_add',
                             title='Add',
                             title='Add',
                             icon_class='mdi mdi-plus-thick',
                             icon_class='mdi mdi-plus-thick',
                             permissions=[f'auth.add_group']
                             permissions=[f'auth.add_group']
                         ),
                         ),
                         MenuItemButton(
                         MenuItemButton(
-                            link=f'users:netboxgroup_import',
+                            link=f'users:group_import',
                             title='Import',
                             title='Import',
                             icon_class='mdi mdi-upload',
                             icon_class='mdi mdi-upload',
                             permissions=[f'auth.add_group']
                             permissions=[f'auth.add_group']

+ 1 - 2
netbox/netbox/tests/test_authentication.py

@@ -2,7 +2,6 @@ import datetime
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import Client
 from django.test import Client
 from django.test.utils import override_settings
 from django.test.utils import override_settings
@@ -12,7 +11,7 @@ from rest_framework.test import APIClient
 
 
 from dcim.models import Site
 from dcim.models import Site
 from ipam.models import Prefix
 from ipam.models import Prefix
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 from utilities.testing import TestCase
 from utilities.testing import TestCase
 from utilities.testing.api import APITestCase
 from utilities.testing.api import APITestCase
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


BIN
netbox/project-static/img/tint_20.png


+ 6 - 5
netbox/project-static/src/objectSelector.ts

@@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
   const value = link.getAttribute('data-value');
   const value = link.getAttribute('data-value');
 
 
   //@ts-ignore
   //@ts-ignore
-  target.slim.setData([
-    {text: label, value: value}
-  ]);
-  const change = new Event('change');
-  target.dispatchEvent(change);
+  target.tomselect.addOption({
+    id: value,
+    display: label,
+  });
+  //@ts-ignore
+  target.tomselect.addItem(value);
 
 
 }
 }
 
 

+ 1 - 1
netbox/project-static/src/select/dynamic.ts

@@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
 
 
 // Initialize <select> elements which are populated via a REST API call
 // Initialize <select> elements which are populated via a REST API call
 export function initDynamicSelects(): void {
 export function initDynamicSelects(): void {
-  for (const select of getElements<HTMLSelectElement>('select.api-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.api-select:not(.tomselected)')) {
     new DynamicTomSelect(select, {
     new DynamicTomSelect(select, {
       ...config,
       ...config,
       valueField: VALUE_FIELD,
       valueField: VALUE_FIELD,

+ 2 - 2
netbox/project-static/src/select/static.ts

@@ -7,7 +7,7 @@ import { getElements } from '../util';
 // Initialize <select> elements with statically-defined options
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
   for (const select of getElements<HTMLSelectElement>(
-    'select:not(.api-select):not(.color-select)',
+    'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
   )) {
   )) {
     new TomSelect(select, {
     new TomSelect(select, {
       ...config,
       ...config,
@@ -24,7 +24,7 @@ export function initColorSelects(): void {
     )}"></span> ${escape(item.text)}</div>`;
     )}"></span> ${escape(item.text)}</div>`;
   }
   }
 
 
-  for (const select of getElements<HTMLSelectElement>('select.color-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
     new TomSelect(select, {
     new TomSelect(select, {
       ...config,
       ...config,
       maxOptions: undefined,
       maxOptions: undefined,

+ 0 - 23
netbox/project-static/src/util.ts

@@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
   return selected;
   return selected;
 }
 }
 
 
-/**
- * Get data that can only be accessed via Django context, and is thus already rendered in the HTML
- * template.
- *
- * @see Templates requiring Django context data have a `{% block data %}` block.
- *
- * @param key Property name, which must exist on the HTML element. If not already prefixed with
- *            `data-`, `data-` will be prepended to the property.
- * @returns Value if it exists, `null` if not.
- */
-export function getNetboxData(key: string): string | null {
-  if (!key.startsWith('data-')) {
-    key = `data-${key}`;
-  }
-  for (const element of getElements('body > div#netbox-data > *')) {
-    const value = element.getAttribute(key);
-    if (isTruthy(value)) {
-      return value;
-    }
-  }
-  return null;
-}
-
 /**
 /**
  * Toggle visibility of an element.
  * Toggle visibility of an element.
  */
  */

+ 7 - 0
netbox/project-static/styles/custom/_markdown.scss

@@ -28,6 +28,13 @@
 
 
 }
 }
 
 
+// Remove the bottom margin of <p> elements inside a table cell
+td > .rendered-markdown {
+  p:last-of-type {
+    margin-bottom: 0;
+  }
+}
+
 // Markdown preview
 // Markdown preview
 .markdown-widget {
 .markdown-widget {
   .preview {
   .preview {

+ 1 - 1
netbox/project-static/styles/custom/_misc.scss

@@ -2,7 +2,7 @@
 
 
 // Color labels
 // Color labels
 span.color-label {
 span.color-label {
-  display: block;
+  display: inline-block;
   width: 5rem;
   width: 5rem;
   height: 1rem;
   height: 1rem;
   padding: $badge-padding-y $badge-padding-x;
   padding: $badge-padding-y $badge-padding-x;

+ 4 - 0
netbox/project-static/styles/overrides/_tabler.scss

@@ -9,6 +9,10 @@ pre {
   // Tabler sets display: flex
   // Tabler sets display: flex
   display: inline-block;
   display: inline-block;
 }
 }
+.btn-sm {
+  // $border-radius-sm (2px) is too small
+  border-radius: $border-radius;
+}
 
 
 // Tabs
 // Tabs
 .nav-tabs {
 .nav-tabs {

+ 0 - 1
netbox/project-static/styles/transitional/_tables.scss

@@ -23,7 +23,6 @@ table.attr-table {
 
 
   // Restyle row header
   // Restyle row header
   th {
   th {
-    color: $gray-700;
     font-weight: normal;
     font-weight: normal;
     width: min-content;
     width: min-content;
   }
   }

+ 0 - 5
netbox/templates/base/base.html

@@ -70,10 +70,5 @@
     {# User messages #}
     {# User messages #}
     {% include 'inc/messages.html' %}
     {% include 'inc/messages.html' %}
 
 
-    {# Data container #}
-    <div id="netbox-data" style="display: none!important; visibility: hidden!important">
-      {% block data %}{% endblock %}
-    </div>
-
   </body>
   </body>
 </html>
 </html>

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -163,7 +163,7 @@
 	  </div>
 	  </div>
     <div class="col col-12 col-xl-7">
     <div class="col col-12 col-xl-7">
       <div class="text-end mb-4">
       <div class="text-end mb-4">
-        <select class="btn btn-outline-dark rack-view">
+        <select class="btn btn-outline-secondary no-ts rack-view">
           <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
           <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
           <option value="images-only">{% trans "Images only" %}</option>
           <option value="images-only">{% trans "Images only" %}</option>
           <option value="labels-only">{% trans "Labels only" %}</option>
           <option value="labels-only">{% trans "Labels only" %}</option>

+ 5 - 7
netbox/templates/dcim/rack_elevation_list.html

@@ -11,13 +11,11 @@
       <a href="{% url 'dcim:rack_list' %}{% querystring request %}" class="btn btn-primary">
       <a href="{% url 'dcim:rack_list' %}{% querystring request %}" class="btn btn-primary">
         <i class="mdi mdi-format-list-checkbox"></i> {% trans "View List" %}
         <i class="mdi mdi-format-list-checkbox"></i> {% trans "View List" %}
       </a>
       </a>
-      <div class="btn-group" role="group">
-        <select class="btn btn-outline-secondary rack-view">
-          <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
-          <option value="images-only">{% trans "Images only" %}</option>
-          <option value="labels-only">{% trans "Labels only" %}</option>
-        </select>
-      </div>
+      <select class="btn btn-outline-secondary no-ts rack-view">
+        <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
+        <option value="images-only">{% trans "Images only" %}</option>
+        <option value="labels-only">{% trans "Labels only" %}</option>
+      </select>
       <div class="btn-group" role="group">
       <div class="btn-group" role="group">
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">{% trans "Front" %}</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">{% trans "Front" %}</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">{% trans "Rear" %}</a>
         <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">{% trans "Rear" %}</a>

+ 1 - 1
netbox/templates/inc/toast.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 {% load helpers %}
 
 
-<div class="toast shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
+<div class="toast toast-dark border-0 shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
   <div class="toast-header text-bg-{{ status }}">
   <div class="toast-header text-bg-{{ status }}">
     <i class="mdi mdi-{{ status|icon_from_status }} me-1"></i>
     <i class="mdi mdi-{{ status|icon_from_status }} me-1"></i>
     {{ title }}
     {{ title }}

+ 1 - 1
netbox/templates/users/group.html

@@ -24,7 +24,7 @@
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Users" %}</h5>
         <h5 class="card-header">{% trans "Users" %}</h5>
         <div class="list-group list-group-flush">
         <div class="list-group list-group-flush">
-          {% for user in object.user_set.all %}
+          {% for user in object.users.all %}
             <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
             <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
           {% empty %}
           {% empty %}
             <div class="list-group-item text-muted">{% trans "None" %}</div>
             <div class="list-group-item text-muted">{% trans "None" %}</div>

+ 1 - 1
netbox/templates/users/objectpermission.html

@@ -82,7 +82,7 @@
         <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
         <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
         <div class="list-group list-group-flush">
         <div class="list-group list-group-flush">
           {% for group in object.groups.all %}
           {% for group in object.groups.all %}
-            <a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
+            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
           {% empty %}
           {% empty %}
             <div class="list-group-item text-muted">{% trans "None" %}</div>
             <div class="list-group-item text-muted">{% trans "None" %}</div>
           {% endfor %}
           {% endfor %}

+ 1 - 1
netbox/templates/users/user.html

@@ -53,7 +53,7 @@
         <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
         <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
         <div class="list-group list-group-flush">
         <div class="list-group list-group-flush">
           {% for group in object.groups.all %}
           {% for group in object.groups.all %}
-            <a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
+            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
           {% empty %}
           {% empty %}
             <div class="list-group-item text-muted">{% trans "None" %}</div>
             <div class="list-group-item text-muted">{% trans "None" %}</div>
           {% endfor %}
           {% endfor %}

+ 1 - 2
netbox/users/api/nested_serializers.py

@@ -1,5 +1,4 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
@@ -7,7 +6,7 @@ from rest_framework import serializers
 
 
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 
 
 __all__ = [
 __all__ = [
     'NestedGroupSerializer',
     'NestedGroupSerializer',

+ 1 - 2
netbox/users/api/serializers_/permissions.py

@@ -1,11 +1,10 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
-from users.models import ObjectPermission
+from users.models import Group, ObjectPermission
 from .users import GroupSerializer, UserSerializer
 from .users import GroupSerializer, UserSerializer
 
 
 __all__ = (
 __all__ = (

+ 1 - 1
netbox/users/api/serializers_/users.py

@@ -1,11 +1,11 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from netbox.api.fields import SerializedPKRelatedField
 from netbox.api.fields import SerializedPKRelatedField
 from netbox.api.serializers import ValidatedModelSerializer
 from netbox.api.serializers import ValidatedModelSerializer
+from users.models import Group
 
 
 __all__ = (
 __all__ = (
     'GroupSerializer',
     'GroupSerializer',

+ 4 - 6
netbox/users/api/views.py

@@ -1,11 +1,9 @@
 import logging
 import logging
-from django.contrib.auth import authenticate
+
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.db.models import Count
 from django.db.models import Count
-from drf_spectacular.utils import extend_schema
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
-from rest_framework.exceptions import AuthenticationFailed
+from drf_spectacular.utils import extend_schema
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
@@ -15,7 +13,7 @@ from rest_framework.viewsets import ViewSet
 
 
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from users import filtersets
 from users import filtersets
-from users.models import ObjectPermission, Token, UserConfig
+from users.models import Group, ObjectPermission, Token, UserConfig
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import deepmerge
 from utilities.utils import deepmerge
 from . import serializers
 from . import serializers
@@ -40,7 +38,7 @@ class UserViewSet(NetBoxModelViewSet):
 
 
 
 
 class GroupViewSet(NetBoxModelViewSet):
 class GroupViewSet(NetBoxModelViewSet):
-    queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name')
+    queryset = Group.objects.annotate(user_count=Count('user'))
     serializer_class = serializers.GroupSerializer
     serializer_class = serializers.GroupSerializer
     filterset_class = filtersets.GroupFilterSet
     filterset_class = filtersets.GroupFilterSet
 
 

+ 1 - 2
netbox/users/filtersets.py

@@ -1,11 +1,10 @@
 import django_filters
 import django_filters
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from netbox.filtersets import BaseFilterSet
 from netbox.filtersets import BaseFilterSet
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 
 
 __all__ = (
 __all__ = (
     'GroupFilterSet',
     'GroupFilterSet',

+ 1 - 1
netbox/users/forms/bulk_import.py

@@ -14,7 +14,7 @@ __all__ = (
 class GroupImportForm(CSVModelForm):
 class GroupImportForm(CSVModelForm):
 
 
     class Meta:
     class Meta:
-        model = NetBoxGroup
+        model = Group
         fields = (
         fields = (
             'name',
             'name',
         )
         )

+ 2 - 3
netbox/users/forms/filtersets.py

@@ -1,11 +1,10 @@
 from django import forms
 from django import forms
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.forms.mixins import SavedFiltersMixin
-from users.models import NetBoxGroup, User, ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token, User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 from utilities.forms.widgets import DateTimePicker
 from utilities.forms.widgets import DateTimePicker
@@ -19,7 +18,7 @@ __all__ = (
 
 
 
 
 class GroupFilterForm(NetBoxModelFilterSetForm):
 class GroupFilterForm(NetBoxModelFilterSetForm):
-    model = NetBoxGroup
+    model = Group
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id',)),
         (None, ('q', 'filter_id',)),
     )
     )

+ 3 - 4
netbox/users/forms/model_forms.py

@@ -1,7 +1,6 @@
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import FieldError
 from django.core.exceptions import FieldError
@@ -253,7 +252,7 @@ class GroupForm(forms.ModelForm):
     )
     )
 
 
     class Meta:
     class Meta:
-        model = NetBoxGroup
+        model = Group
         fields = [
         fields = [
             'name', 'users', 'object_permissions',
             'name', 'users', 'object_permissions',
         ]
         ]
@@ -263,14 +262,14 @@ class GroupForm(forms.ModelForm):
 
 
         # Populate assigned users and permissions
         # Populate assigned users and permissions
         if self.instance.pk:
         if self.instance.pk:
-            self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True)
+            self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
             self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
             self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         instance = super().save(*args, **kwargs)
         instance = super().save(*args, **kwargs)
 
 
         # Update assigned users and permissions
         # Update assigned users and permissions
-        instance.user_set.set(self.cleaned_data['users'])
+        instance.users.set(self.cleaned_data['users'])
         instance.object_permissions.set(self.cleaned_data['object_permissions'])
         instance.object_permissions.set(self.cleaned_data['object_permissions'])
 
 
         return instance
         return instance

+ 3 - 3
netbox/users/graphql/schema.py

@@ -1,10 +1,10 @@
 import graphene
 import graphene
-
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
+
 from netbox.graphql.fields import ObjectField, ObjectListField
 from netbox.graphql.fields import ObjectField, ObjectListField
-from .types import *
+from users.models import Group
 from utilities.graphql_optimizer import gql_query_optimizer
 from utilities.graphql_optimizer import gql_query_optimizer
+from .types import *
 
 
 
 
 class UsersQuery(graphene.ObjectType):
 class UsersQuery(graphene.ObjectType):

+ 1 - 1
netbox/users/graphql/types.py

@@ -1,8 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from graphene_django import DjangoObjectType
 from graphene_django import DjangoObjectType
 
 
 from users import filtersets
 from users import filtersets
+from users.models import Group
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
 __all__ = (
 __all__ = (

+ 16 - 4
netbox/users/migrations/0005_alter_user_table.py

@@ -1,5 +1,3 @@
-# Generated by Django 5.0.1 on 2024-01-31 23:18
-
 from django.db import migrations
 from django.db import migrations
 
 
 
 
@@ -27,12 +25,26 @@ class Migration(migrations.Migration):
     ]
     ]
 
 
     operations = [
     operations = [
-        # 0001_squashed had model with db_table=auth_user - now we switch it
-        # to None to use the default Django resolution (users.user)
+        # The User table was originally created as 'auth_user'. Now we nullify the model's
+        # db_table option, so that it defaults to the app & model name (users_user). This
+        # causes the database table to be renamed.
         migrations.AlterModelTable(
         migrations.AlterModelTable(
             name='user',
             name='user',
             table=None,
             table=None,
         ),
         ),
+
+        # Rename auth_user_* sequences
+        migrations.RunSQL("ALTER TABLE auth_user_groups_id_seq RENAME TO users_user_groups_id_seq"),
+        migrations.RunSQL("ALTER TABLE auth_user_id_seq RENAME TO users_user_id_seq"),
+        migrations.RunSQL("ALTER TABLE auth_user_user_permissions_id_seq RENAME TO users_user_user_permissions_id_seq"),
+
+        # Rename auth_user_* indexes
+        migrations.RunSQL("ALTER INDEX auth_user_pkey RENAME TO users_user_pkey"),
+        # Hash is deterministic; generated via schema_editor._create_index_name()
+        migrations.RunSQL("ALTER INDEX auth_user_username_6821ab7c_like RENAME TO users_user_username_06e46fe6_like"),
+        migrations.RunSQL("ALTER INDEX auth_user_username_key RENAME TO users_user_username_key"),
+
+        # Update ContentTypes
         migrations.RunPython(
         migrations.RunPython(
             code=update_content_types,
             code=update_content_types,
             reverse_code=migrations.RunPython.noop
             reverse_code=migrations.RunPython.noop

+ 80 - 0
netbox/users/migrations/0006_custom_group_model.py

@@ -0,0 +1,80 @@
+import users.models
+from django.db import migrations, models
+
+
+def update_custom_fields(apps, schema_editor):
+    """
+    Update any CustomFields referencing the old Group model to use the new model.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    CustomField = apps.get_model('extras', 'CustomField')
+    Group = apps.get_model('users', 'Group')
+
+    if old_ct := ContentType.objects.filter(app_label='users', model='netboxgroup').first():
+        new_ct = ContentType.objects.get_for_model(Group)
+        CustomField.objects.filter(object_type=old_ct).update(object_type=new_ct)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0005_alter_user_table'),
+    ]
+
+    operations = [
+        # Create the new Group model & table
+        migrations.CreateModel(
+            name='Group',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=150, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('permissions', models.ManyToManyField(blank=True, related_name='groups', related_query_name='group', to='auth.permission')),
+            ],
+            options={
+                'verbose_name': 'group',
+                'verbose_name_plural': 'groups',
+            },
+            managers=[
+                ('objects', users.models.NetBoxGroupManager()),
+            ],
+        ),
+
+        # Copy existing groups from the old table into the new one
+        migrations.RunSQL(
+            "INSERT INTO users_group (SELECT id, name, '' AS description FROM auth_group)"
+        ),
+
+        # Update the sequence for group ID values
+        migrations.RunSQL(
+            "SELECT setval('users_group_id_seq', (SELECT MAX(id) FROM users_group))"
+        ),
+
+        # Update the "groups" M2M fields on User & ObjectPermission
+        migrations.AlterField(
+            model_name='user',
+            name='groups',
+            field=models.ManyToManyField(blank=True, related_name='users', related_query_name='user', to='users.group'),
+        ),
+        migrations.AlterField(
+            model_name='objectpermission',
+            name='groups',
+            field=models.ManyToManyField(blank=True, related_name='object_permissions', to='users.group'),
+        ),
+
+        # Delete groups from the old table
+        migrations.RunSQL(
+            "DELETE from auth_group"
+        ),
+
+        # Update custom fields
+        migrations.RunPython(
+            code=update_custom_fields,
+            reverse_code=migrations.RunPython.noop
+        ),
+
+        # Delete the proxy model
+        migrations.DeleteModel(
+            name='NetBoxGroup',
+        ),
+    ]

+ 56 - 28
netbox/users/models.py

@@ -4,7 +4,12 @@ import os
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import (
 from django.contrib.auth.models import (
-    AbstractUser, Group, GroupManager, User as DjangoUser, UserManager as DjangoUserManager
+    AbstractUser,
+    Group as DjangoGroup,
+    GroupManager,
+    Permission,
+    User as DjangoUser,
+    UserManager as DjangoUserManager
 )
 )
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -25,7 +30,7 @@ from utilities.utils import flatten_dict
 from .constants import *
 from .constants import *
 
 
 __all__ = (
 __all__ = (
-    'NetBoxGroup',
+    'Group',
     'ObjectPermission',
     'ObjectPermission',
     'Token',
     'Token',
     'User',
     'User',
@@ -33,22 +38,61 @@ __all__ = (
 )
 )
 
 
 
 
-#
-# Proxies for Django's User and Group models
-#
-
-class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)):
+class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
     pass
     pass
 
 
 
 
-class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
+class Group(models.Model):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=150,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+
+    # Replicate legacy Django permissions support from stock Group model
+    # to ensure authentication backend compatibility
+    permissions = models.ManyToManyField(
+        Permission,
+        verbose_name=_("permissions"),
+        blank=True,
+        related_name='groups',
+        related_query_name='group'
+    )
+
+    objects = NetBoxGroupManager()
+
+    class Meta:
+        verbose_name = _('group')
+        verbose_name_plural = _('groups')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('users:group', args=[self.pk])
+
+    def natural_key(self):
+        return (self.name,)
+
+
+class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)):
     pass
     pass
 
 
 
 
 class User(AbstractUser):
 class User(AbstractUser):
-    """
-    Proxy contrib.auth.models.User for the UI
-    """
+    groups = models.ManyToManyField(
+        to='users.Group',
+        verbose_name=_('groups'),
+        blank=True,
+        related_name='users',
+        related_query_name='user'
+    )
+
     objects = UserManager()
     objects = UserManager()
 
 
     class Meta:
     class Meta:
@@ -68,22 +112,6 @@ class User(AbstractUser):
             raise ValidationError(_("A user with this username already exists."))
             raise ValidationError(_("A user with this username already exists."))
 
 
 
 
-class NetBoxGroup(Group):
-    """
-    Proxy contrib.auth.models.User for the UI
-    """
-    objects = NetBoxGroupManager()
-
-    class Meta:
-        proxy = True
-        ordering = ('name',)
-        verbose_name = _('group')
-        verbose_name_plural = _('groups')
-
-    def get_absolute_url(self):
-        return reverse('users:netboxgroup', args=[self.pk])
-
-
 #
 #
 # User preferences
 # User preferences
 #
 #
@@ -360,7 +388,7 @@ class ObjectPermission(models.Model):
         related_name='object_permissions'
         related_name='object_permissions'
     )
     )
     groups = models.ManyToManyField(
     groups = models.ManyToManyField(
-        to=Group,
+        to='users.Group',
         blank=True,
         blank=True,
         related_name='object_permissions'
         related_name='object_permissions'
     )
     )

+ 4 - 4
netbox/users/tables.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 
 from account.tables import UserTokenTable
 from account.tables import UserTokenTable
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from users.models import NetBoxGroup, User, ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token, User
 
 
 __all__ = (
 __all__ = (
     'GroupTable',
     'GroupTable',
@@ -33,7 +33,7 @@ class UserTable(NetBoxTable):
     )
     )
     groups = columns.ManyToManyColumn(
     groups = columns.ManyToManyColumn(
         verbose_name=_('Groups'),
         verbose_name=_('Groups'),
-        linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+        linkify_item=('users:group', {'pk': tables.A('pk')})
     )
     )
     is_active = columns.BooleanColumn(
     is_active = columns.BooleanColumn(
         verbose_name=_('Is Active'),
         verbose_name=_('Is Active'),
@@ -67,7 +67,7 @@ class GroupTable(NetBoxTable):
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
-        model = NetBoxGroup
+        model = Group
         fields = (
         fields = (
             'pk', 'id', 'name', 'users_count',
             'pk', 'id', 'name', 'users_count',
         )
         )
@@ -107,7 +107,7 @@ class ObjectPermissionTable(NetBoxTable):
     )
     )
     groups = columns.ManyToManyColumn(
     groups = columns.ManyToManyColumn(
         verbose_name=_('Groups'),
         verbose_name=_('Groups'),
-        linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+        linkify_item=('users:group', {'pk': tables.A('pk')})
     )
     )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),

+ 1 - 2
netbox/users/tests/test_api.py

@@ -1,9 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 from utilities.testing import APIViewTestCases, APITestCase, create_test_user
 from utilities.testing import APIViewTestCases, APITestCase, create_test_user
 from utilities.utils import deepmerge
 from utilities.utils import deepmerge
 
 

+ 1 - 2
netbox/users/tests/test_filtersets.py

@@ -1,13 +1,12 @@
 import datetime
 import datetime
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
 
 
 from users import filtersets
 from users import filtersets
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 from utilities.testing import BaseFilterSetTests
 from utilities.testing import BaseFilterSetTests
 
 
 User = get_user_model()
 User = get_user_model()

+ 1 - 2
netbox/users/tests/test_views.py

@@ -1,4 +1,3 @@
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from users.models import *
 from users.models import *
@@ -70,7 +69,7 @@ class GroupTestCase(
     ViewTestCases.BulkImportObjectsViewTestCase,
     ViewTestCases.BulkImportObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase,
 ):
 ):
-    model = NetBoxGroup
+    model = Group
     maxDiff = None
     maxDiff = None
 
 
     @classmethod
     @classmethod

+ 5 - 5
netbox/users/urls.py

@@ -23,11 +23,11 @@ urlpatterns = [
     path('users/<int:pk>/', include(get_model_urls('users', 'user'))),
     path('users/<int:pk>/', include(get_model_urls('users', 'user'))),
 
 
     # Groups
     # Groups
-    path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'),
-    path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'),
-    path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'),
-    path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'),
-    path('groups/<int:pk>/', include(get_model_urls('users', 'netboxgroup'))),
+    path('groups/', views.GroupListView.as_view(), name='group_list'),
+    path('groups/add/', views.GroupEditView.as_view(), name='group_add'),
+    path('groups/import/', views.GroupBulkImportView.as_view(), name='group_import'),
+    path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='group_bulk_delete'),
+    path('groups/<int:pk>/', include(get_model_urls('users', 'group'))),
 
 
     # Permissions
     # Permissions
     path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),
     path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),

+ 10 - 10
netbox/users/views.py

@@ -5,7 +5,7 @@ from extras.tables import ObjectChangeTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.views import register_model_view
 from utilities.views import register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
-from .models import NetBoxGroup, User, ObjectPermission, Token
+from .models import Group, User, ObjectPermission, Token
 
 
 
 
 #
 #
@@ -110,36 +110,36 @@ class UserBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class GroupListView(generic.ObjectListView):
 class GroupListView(generic.ObjectListView):
-    queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+    queryset = Group.objects.annotate(users_count=Count('user'))
     filterset = filtersets.GroupFilterSet
     filterset = filtersets.GroupFilterSet
     filterset_form = forms.GroupFilterForm
     filterset_form = forms.GroupFilterForm
     table = tables.GroupTable
     table = tables.GroupTable
 
 
 
 
-@register_model_view(NetBoxGroup)
+@register_model_view(Group)
 class GroupView(generic.ObjectView):
 class GroupView(generic.ObjectView):
-    queryset = NetBoxGroup.objects.all()
+    queryset = Group.objects.all()
     template_name = 'users/group.html'
     template_name = 'users/group.html'
 
 
 
 
-@register_model_view(NetBoxGroup, 'edit')
+@register_model_view(Group, 'edit')
 class GroupEditView(generic.ObjectEditView):
 class GroupEditView(generic.ObjectEditView):
-    queryset = NetBoxGroup.objects.all()
+    queryset = Group.objects.all()
     form = forms.GroupForm
     form = forms.GroupForm
 
 
 
 
-@register_model_view(NetBoxGroup, 'delete')
+@register_model_view(Group, 'delete')
 class GroupDeleteView(generic.ObjectDeleteView):
 class GroupDeleteView(generic.ObjectDeleteView):
-    queryset = NetBoxGroup.objects.all()
+    queryset = Group.objects.all()
 
 
 
 
 class GroupBulkImportView(generic.BulkImportView):
 class GroupBulkImportView(generic.BulkImportView):
-    queryset = NetBoxGroup.objects.all()
+    queryset = Group.objects.all()
     model_form = forms.GroupImportForm
     model_form = forms.GroupImportForm
 
 
 
 
 class GroupBulkDeleteView(generic.BulkDeleteView):
 class GroupBulkDeleteView(generic.BulkDeleteView):
-    queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+    queryset = Group.objects.annotate(users_count=Count('user'))
     filterset = filtersets.GroupFilterSet
     filterset = filtersets.GroupFilterSet
     table = tables.GroupTable
     table = tables.GroupTable
 
 

+ 3 - 13
netbox/utilities/api.py

@@ -34,23 +34,13 @@ def get_serializer_for_model(model, prefix=''):
     """
     """
     Dynamically resolve and return the appropriate serializer for a model.
     Dynamically resolve and return the appropriate serializer for a model.
     """
     """
-    app_name, model_name = model._meta.label.split('.')
-    # Serializers for Django's auth models are in the users app
-    if app_name == 'auth':
-        app_name = 'users'
-    # Account for changes using Proxy model
-    if app_name == 'users':
-        if model_name == 'NetBoxUser':
-            model_name = 'User'
-        elif model_name == 'NetBoxGroup':
-            model_name = 'Group'
-
-    serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer'
+    app_label, model_name = model._meta.label.split('.')
+    serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
     try:
     try:
         return dynamic_import(serializer_name)
         return dynamic_import(serializer_name)
     except AttributeError:
     except AttributeError:
         raise SerializerNotFound(
         raise SerializerNotFound(
-            f"Could not determine serializer for {app_name}.{model_name} with prefix '{prefix}'"
+            f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'"
         )
         )
 
 
 
 

+ 1 - 1
netbox/utilities/templates/form_helpers/render_field.html

@@ -36,7 +36,7 @@
     {% elif 'data-clipboard' in field.field.widget.attrs %}
     {% elif 'data-clipboard' in field.field.widget.attrs %}
       <div class="input-group">
       <div class="input-group">
         {{ field }}
         {{ field }}
-        <button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark copy-content" data-clipboard-target="#{{ field.id_for_label }}">
+        <button type="button" title="{% trans "Copy to clipboard" %}" class="btn copy-content" data-clipboard-target="#{{ field.id_for_label }}">
           <i class="mdi mdi-content-copy"></i>
           <i class="mdi mdi-content-copy"></i>
         </button>
         </button>
       </div>
       </div>

+ 1 - 1
netbox/utilities/templates/navigation/menu.html

@@ -19,7 +19,7 @@
         <div class="dropdown-menu-columns">
         <div class="dropdown-menu-columns">
           <div class="dropdown-menu-column pb-2">
           <div class="dropdown-menu-column pb-2">
             {% for group, items in groups %}
             {% for group, items in groups %}
-              <div class="text-uppercase fw-bold fs-5 ps-3 pt-3 pb-1">
+              <div class="text-uppercase text-secondary fw-bold fs-5 ps-3 pt-3 pb-1">
                 {{ group.label }}
                 {{ group.label }}
               </div>
               </div>
               {% for item, buttons in items %}
               {% for item, buttons in items %}

+ 3 - 1
netbox/utilities/templates/widgets/number_with_options.html

@@ -1,6 +1,8 @@
 <div class="input-group">
 <div class="input-group">
   {% include 'django/forms/widgets/number.html' %}
   {% include 'django/forms/widgets/number.html' %}
-  <button type="button" class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown"></button>
+  <button type="button" class="btn" data-bs-toggle="dropdown">
+    <i class="mdi mdi-chevron-down"></i>
+  </button>
   <ul class="dropdown-menu dropdown-menu-end">
   <ul class="dropdown-menu dropdown-menu-end">
     {% for value, label in widget.options %}
     {% for value, label in widget.options %}
       <li>
       <li>

+ 4 - 14
netbox/utilities/utils.py

@@ -1,11 +1,12 @@
 import datetime
 import datetime
 import decimal
 import decimal
 import json
 import json
-import nh3
 import re
 import re
 from decimal import Decimal
 from decimal import Decimal
 from itertools import count, groupby
 from itertools import count, groupby
+from urllib.parse import urlencode
 
 
+import nh3
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core import serializers
 from django.core import serializers
 from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
 from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
@@ -23,7 +24,6 @@ from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
 from extras.utils import is_taggable
 from extras.utils import is_taggable
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
-from urllib.parse import urlencode
 from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
 from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
 from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS
 from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS
 
 
@@ -48,26 +48,16 @@ def get_viewname(model, action=None, rest_api=False):
     model_name = model._meta.model_name
     model_name = model._meta.model_name
 
 
     if rest_api:
     if rest_api:
+        viewname = f'{app_label}-api:{model_name}'
         if is_plugin:
         if is_plugin:
-            viewname = f'plugins-api:{app_label}-api:{model_name}'
-        else:
-            # Alter the app_label for group and user model_name to point to users app
-            if app_label == 'auth' and model_name in ['group', 'user']:
-                app_label = 'users'
-            if app_label == 'users' and model._meta.proxy and model_name in ['netboxuser', 'netboxgroup']:
-                model_name = model._meta.proxy_for_model._meta.model_name
-
-            viewname = f'{app_label}-api:{model_name}'
-        # Append the action, if any
+            viewname = f'plugins-api:{viewname}'
         if action:
         if action:
             viewname = f'{viewname}-{action}'
             viewname = f'{viewname}-{action}'
 
 
     else:
     else:
         viewname = f'{app_label}:{model_name}'
         viewname = f'{app_label}:{model_name}'
-        # Prepend the plugins namespace if this is a plugin model
         if is_plugin:
         if is_plugin:
             viewname = f'plugins:{viewname}'
             viewname = f'plugins:{viewname}'
-        # Append the action, if any
         if action:
         if action:
             viewname = f'{viewname}_{action}'
             viewname = f'{viewname}_{action}'
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов