From 125a493dc6baed50a834dbbc3963212796a421d1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Feb 2024 11:37:23 -0500 Subject: [PATCH 1/4] Changelog for #14438, #15042, #15087, #15131, #15238 --- docs/release-notes/version-4.0.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index 9bf0a4db8..4bae93fa8 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -13,6 +13,10 @@ 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 * [#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 * [#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 +* [#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 @@ -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` * [#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 +* [#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 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class From 709eac6b9838fe1129f1371ab793d25bc656913c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 29 Feb 2024 08:53:45 -0500 Subject: [PATCH 2/4] Closes #15292: Remove obsolete device_role attribute from Device model --- docs/customization/custom-scripts.md | 2 +- docs/models/dcim/device.md | 4 ++-- netbox/dcim/api/serializers.py | 32 ++++++++++++---------------- netbox/dcim/models/devices.py | 14 ------------ netbox/dcim/tests/test_cablepaths.py | 2 +- netbox/dcim/tests/test_models.py | 24 --------------------- 6 files changed, 18 insertions(+), 60 deletions(-) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 671f3ab17..bdc3f9104 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -476,7 +476,7 @@ class NewBranchScript(Script): name=f'{site.slug}-switch{i}', site=site, status=DeviceStatusChoices.STATUS_PLANNED, - device_role=switch_role + role=switch_role ) switch.full_clean() switch.save() diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index c9f05cd93..8b38d7c89 100644 --- a/docs/models/dcim/device.md +++ b/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. -### Device Role +### Role -The functional [role](./devicerole.md) assigned to this device. +The functional [device role](./devicerole.md) assigned to this device. ### Device Type diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1bf4969e2..8fbe9fd04 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -702,7 +702,6 @@ class DeviceSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() role = NestedDeviceRoleSerializer() - device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.') tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() @@ -744,13 +743,13 @@ class DeviceSerializer(NetBoxModelSerializer): class Meta: model = Device 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') @@ -765,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer): data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data return data - def get_device_role(self, obj): - return obj.role - class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField(read_only=True) class Meta(DeviceSerializer.Meta): 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)) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 5e773364a..c75757fa7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -815,20 +815,6 @@ class Device( def get_absolute_url(self): 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): super().clean() diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a827939f7..49a71022e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase): device = Device.objects.create( site=self.site, device_type=self.device.device_type, - device_role=self.device.device_role, + role=self.device.role, name='Test mid-span Device' ) interface1 = Interface.objects.create(device=self.device, name='Interface 1') diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index d56bf0741..8eb057020 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -533,30 +533,6 @@ class DeviceTestCase(TestCase): device2.full_clean() 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): From c6a3fc2407fe598cca2bb71a7a124d30615256c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 4 Mar 2024 08:29:53 -0500 Subject: [PATCH 3/4] #12795: Introduce a custom Group model (#15304) * Rename sequences & indexes after renaming users table * Migrate from auth.Group to a custom group model * Delete original groups from auth_group table * Update object & multi-object custom fields referencing the Group model * Fix ContentType resolution * Clean up obsolete logic for view/serializer resolution --- netbox/netbox/authentication.py | 4 +- netbox/netbox/navigation/menu.py | 6 +- netbox/netbox/tests/test_authentication.py | 3 +- netbox/templates/users/group.html | 2 +- netbox/templates/users/objectpermission.html | 2 +- netbox/templates/users/user.html | 2 +- netbox/users/api/nested_serializers.py | 3 +- netbox/users/api/serializers.py | 3 +- netbox/users/api/views.py | 10 +-- netbox/users/filtersets.py | 3 +- netbox/users/forms/bulk_import.py | 2 +- netbox/users/forms/filtersets.py | 5 +- netbox/users/forms/model_forms.py | 7 +- netbox/users/graphql/schema.py | 6 +- netbox/users/graphql/types.py | 2 +- .../users/migrations/0005_alter_user_table.py | 20 ++++- .../migrations/0006_custom_group_model.py | 80 +++++++++++++++++ netbox/users/models.py | 86 ++++++++++++------- netbox/users/tables.py | 8 +- netbox/users/tests/test_api.py | 3 +- netbox/users/tests/test_filtersets.py | 3 +- netbox/users/tests/test_views.py | 3 +- netbox/users/urls.py | 10 +-- netbox/users/views.py | 20 ++--- netbox/utilities/api.py | 16 +--- netbox/utilities/utils.py | 18 +--- 26 files changed, 208 insertions(+), 119 deletions(-) create mode 100644 netbox/users/migrations/0006_custom_group_model.py diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 10555b373..c70c68bc0 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -4,13 +4,13 @@ from collections import defaultdict from django.conf import settings from django.contrib.auth import get_user_model 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.db.models import Q from django.utils.translation import gettext_lazy as _ from users.constants import CONSTRAINT_TOKEN_USER -from users.models import ObjectPermission +from users.models import Group, ObjectPermission from utilities.permissions import ( permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, ) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 2dba76e72..621bd4f5d 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -392,19 +392,19 @@ ADMIN_MENU = Menu( ), # Proxy model for auth.Group MenuItem( - link=f'users:netboxgroup_list', + link=f'users:group_list', link_text=_('Groups'), permissions=[f'auth.view_group'], staff_only=True, buttons=( MenuItemButton( - link=f'users:netboxgroup_add', + link=f'users:group_add', title='Add', icon_class='mdi mdi-plus-thick', permissions=[f'auth.add_group'] ), MenuItemButton( - link=f'users:netboxgroup_import', + link=f'users:group_import', title='Import', icon_class='mdi mdi-upload', permissions=[f'auth.add_group'] diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 1804087d1..6a894edcd 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -2,7 +2,6 @@ import datetime from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import Client from django.test.utils import override_settings @@ -12,7 +11,7 @@ from rest_framework.test import APIClient from dcim.models import Site 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.api import APITestCase diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html index 27b4707fb..d3f02af12 100644 --- a/netbox/templates/users/group.html +++ b/netbox/templates/users/group.html @@ -24,7 +24,7 @@
{% trans "Users" %}
- {% for user in object.user_set.all %} + {% for user in object.users.all %} {{ user }} {% empty %}
{% trans "None" %}
diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html index 9a222ba80..3e8b71327 100644 --- a/netbox/templates/users/objectpermission.html +++ b/netbox/templates/users/objectpermission.html @@ -82,7 +82,7 @@
{% trans "Assigned Groups" %}
{% for group in object.groups.all %} - {{ group }} + {{ group }} {% empty %}
{% trans "None" %}
{% endfor %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 3b08f98d9..0dd12fb52 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -53,7 +53,7 @@
{% trans "Assigned Groups" %}
{% for group in object.groups.all %} - {{ group }} + {{ group }} {% empty %}
{% trans "None" %}
{% endfor %} diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 5e15fa41a..552c24906 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,5 +1,4 @@ from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -7,7 +6,7 @@ from rest_framework import serializers from netbox.api.fields import ContentTypeField from netbox.api.serializers import WritableNestedSerializer -from users.models import ObjectPermission, Token +from users.models import Group, ObjectPermission, Token __all__ = [ 'NestedGroupSerializer', diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 0eef61dc8..b9bd55e75 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -10,7 +9,7 @@ from rest_framework.exceptions import AuthenticationFailed, PermissionDenied from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField from netbox.api.serializers import ValidatedModelSerializer -from users.models import ObjectPermission, Token +from users.models import Group, ObjectPermission, Token from .nested_serializers import * diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 895600822..412bccf59 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,11 +1,9 @@ import logging -from django.contrib.auth import authenticate + from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.db.models import Count -from drf_spectacular.utils import extend_schema 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.response import Response from rest_framework.routers import APIRootView @@ -15,7 +13,7 @@ from rest_framework.viewsets import ViewSet from netbox.api.viewsets import NetBoxModelViewSet 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.utils import deepmerge from . import serializers @@ -40,7 +38,7 @@ class UserViewSet(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 filterset_class = filtersets.GroupFilterSet diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 0f590e012..5dbca7738 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,11 +1,10 @@ import django_filters from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.db.models import Q from django.utils.translation import gettext as _ from netbox.filtersets import BaseFilterSet -from users.models import ObjectPermission, Token +from users.models import Group, ObjectPermission, Token __all__ = ( 'GroupFilterSet', diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 055998c69..cbaa1ad76 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -14,7 +14,7 @@ __all__ = ( class GroupImportForm(CSVModelForm): class Meta: - model = NetBoxGroup + model = Group fields = ( 'name', ) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index c127e2144..23bbe45e1 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -1,11 +1,10 @@ from django import forms from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm 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.fields import DynamicModelMultipleChoiceField from utilities.forms.widgets import DateTimePicker @@ -19,7 +18,7 @@ __all__ = ( class GroupFilterForm(NetBoxModelFilterSetForm): - model = NetBoxGroup + model = Group fieldsets = ( (None, ('q', 'filter_id',)), ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 8875dc7f0..2a024bf47 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -1,7 +1,6 @@ from django import forms from django.conf import settings 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.postgres.forms import SimpleArrayField from django.core.exceptions import FieldError @@ -253,7 +252,7 @@ class GroupForm(forms.ModelForm): ) class Meta: - model = NetBoxGroup + model = Group fields = [ 'name', 'users', 'object_permissions', ] @@ -263,14 +262,14 @@ class GroupForm(forms.ModelForm): # Populate assigned users and permissions 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) def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) # 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']) return instance diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index f033a535a..84ae0c975 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,10 +1,10 @@ import graphene - from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group + from netbox.graphql.fields import ObjectField, ObjectListField -from .types import * +from users.models import Group from utilities.graphql_optimizer import gql_query_optimizer +from .types import * class UsersQuery(graphene.ObjectType): diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 4254f1791..58d211028 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -1,8 +1,8 @@ from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from graphene_django import DjangoObjectType from users import filtersets +from users.models import Group from utilities.querysets import RestrictedQuerySet __all__ = ( diff --git a/netbox/users/migrations/0005_alter_user_table.py b/netbox/users/migrations/0005_alter_user_table.py index 6c4a815dd..e07db6875 100644 --- a/netbox/users/migrations/0005_alter_user_table.py +++ b/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 @@ -27,12 +25,26 @@ class Migration(migrations.Migration): ] 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( name='user', 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( code=update_content_types, reverse_code=migrations.RunPython.noop diff --git a/netbox/users/migrations/0006_custom_group_model.py b/netbox/users/migrations/0006_custom_group_model.py new file mode 100644 index 000000000..282da3ce0 --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 5e817be0b..19d6013c7 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -4,7 +4,12 @@ import os from django.conf import settings from django.contrib.auth import get_user_model 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.core.exceptions import ValidationError @@ -25,7 +30,7 @@ from utilities.utils import flatten_dict from .constants import * __all__ = ( - 'NetBoxGroup', + 'Group', 'ObjectPermission', 'Token', 'User', @@ -33,22 +38,61 @@ __all__ = ( ) -# -# Proxies for Django's User and Group models -# +class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)): + pass + + +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 -class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)): - pass - - 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() class Meta: @@ -68,22 +112,6 @@ class User(AbstractUser): 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 # @@ -360,7 +388,7 @@ class ObjectPermission(models.Model): related_name='object_permissions' ) groups = models.ManyToManyField( - to=Group, + to='users.Group', blank=True, related_name='object_permissions' ) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 781660817..813d729c9 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from account.tables import UserTokenTable from netbox.tables import NetBoxTable, columns -from users.models import NetBoxGroup, User, ObjectPermission, Token +from users.models import Group, ObjectPermission, Token, User __all__ = ( 'GroupTable', @@ -33,7 +33,7 @@ class UserTable(NetBoxTable): ) groups = columns.ManyToManyColumn( verbose_name=_('Groups'), - linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + linkify_item=('users:group', {'pk': tables.A('pk')}) ) is_active = columns.BooleanColumn( verbose_name=_('Is Active'), @@ -67,7 +67,7 @@ class GroupTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = NetBoxGroup + model = Group fields = ( 'pk', 'id', 'name', 'users_count', ) @@ -107,7 +107,7 @@ class ObjectPermissionTable(NetBoxTable): ) groups = columns.ManyToManyColumn( verbose_name=_('Groups'), - linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + linkify_item=('users:group', {'pk': tables.A('pk')}) ) actions = columns.ActionsColumn( actions=('edit', 'delete'), diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 40a3edf31..51fc21c97 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -1,9 +1,8 @@ from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType 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.utils import deepmerge diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 38a0df813..5d373628f 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -1,13 +1,12 @@ import datetime from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.utils.timezone import make_aware from users import filtersets -from users.models import ObjectPermission, Token +from users.models import Group, ObjectPermission, Token from utilities.testing import BaseFilterSetTests User = get_user_model() diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 259b7a857..27d2aeab1 100644 --- a/netbox/users/tests/test_views.py +++ b/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 users.models import * @@ -70,7 +69,7 @@ class GroupTestCase( ViewTestCases.BulkImportObjectsViewTestCase, ViewTestCases.BulkDeleteObjectsViewTestCase, ): - model = NetBoxGroup + model = Group maxDiff = None @classmethod diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 486a0c771..adfeba378 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -23,11 +23,11 @@ urlpatterns = [ path('users//', include(get_model_urls('users', 'user'))), # 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//', 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//', include(get_model_urls('users', 'group'))), # Permissions path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 324125604..662e5e573 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -5,7 +5,7 @@ from extras.tables import ObjectChangeTable from netbox.views import generic from utilities.views import register_model_view 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): - queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + queryset = Group.objects.annotate(users_count=Count('user')) filterset = filtersets.GroupFilterSet filterset_form = forms.GroupFilterForm table = tables.GroupTable -@register_model_view(NetBoxGroup) +@register_model_view(Group) class GroupView(generic.ObjectView): - queryset = NetBoxGroup.objects.all() + queryset = Group.objects.all() template_name = 'users/group.html' -@register_model_view(NetBoxGroup, 'edit') +@register_model_view(Group, 'edit') class GroupEditView(generic.ObjectEditView): - queryset = NetBoxGroup.objects.all() + queryset = Group.objects.all() form = forms.GroupForm -@register_model_view(NetBoxGroup, 'delete') +@register_model_view(Group, 'delete') class GroupDeleteView(generic.ObjectDeleteView): - queryset = NetBoxGroup.objects.all() + queryset = Group.objects.all() class GroupBulkImportView(generic.BulkImportView): - queryset = NetBoxGroup.objects.all() + queryset = Group.objects.all() model_form = forms.GroupImportForm class GroupBulkDeleteView(generic.BulkDeleteView): - queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + queryset = Group.objects.annotate(users_count=Count('user')) filterset = filtersets.GroupFilterSet table = tables.GroupTable diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index a13e62bfd..25a350c81 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -31,23 +31,13 @@ def get_serializer_for_model(model, prefix=''): """ 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: return dynamic_import(serializer_name) except AttributeError: 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}'" ) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index bd03ae4b8..5a25b4465 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,11 +1,12 @@ import datetime import decimal import json -import nh3 import re from decimal import Decimal from itertools import count, groupby +from urllib.parse import urlencode +import nh3 from django.contrib.contenttypes.models import ContentType from django.core import serializers 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 netbox.config import get_config from netbox.plugins import PluginConfig -from urllib.parse import urlencode from utilities.constants import HTTP_REQUEST_META_SAFE_COPY 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 if rest_api: + viewname = f'{app_label}-api:{model_name}' 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: viewname = f'{viewname}-{action}' else: viewname = f'{app_label}:{model_name}' - # Prepend the plugins namespace if this is a plugin model if is_plugin: viewname = f'plugins:{viewname}' - # Append the action, if any if action: viewname = f'{viewname}_{action}' From 239d21870b00a3336549806773a25d888039fba4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 4 Mar 2024 15:55:01 -0500 Subject: [PATCH 4/4] Closes #14871: Complete work on UI cleanup (#15341) * Fix left padding of login button in top menu * Relocate "add" buttons for embedded object tables * Remove unused data template block & getNetboxData() utility function * Remove bottom margin from last

element in rendered Markdown inside a table cell * Prevent TomSelect from initializing on elements with statically-defined options export function initStaticSelects(): void { for (const select of getElements( - 'select:not(.api-select):not(.color-select)', + 'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)', )) { new TomSelect(select, { ...config, @@ -24,7 +24,7 @@ export function initColorSelects(): void { )}"> ${escape(item.text)}

`; } - for (const select of getElements('select.color-select')) { + for (const select of getElements('select.color-select:not(.tomselected)')) { new TomSelect(select, { ...config, maxOptions: undefined, diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index e1ada2e19..3aa8b6676 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -244,29 +244,6 @@ export function getSelectedOptions( 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. */ diff --git a/netbox/project-static/styles/custom/_markdown.scss b/netbox/project-static/styles/custom/_markdown.scss index 08de23581..cb4527f37 100644 --- a/netbox/project-static/styles/custom/_markdown.scss +++ b/netbox/project-static/styles/custom/_markdown.scss @@ -28,6 +28,13 @@ } +// Remove the bottom margin of

elements inside a table cell +td > .rendered-markdown { + p:last-of-type { + margin-bottom: 0; + } +} + // Markdown preview .markdown-widget { .preview { diff --git a/netbox/project-static/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss index ebf66d547..9779bf583 100644 --- a/netbox/project-static/styles/custom/_misc.scss +++ b/netbox/project-static/styles/custom/_misc.scss @@ -2,7 +2,7 @@ // Color labels span.color-label { - display: block; + display: inline-block; width: 5rem; height: 1rem; padding: $badge-padding-y $badge-padding-x; diff --git a/netbox/project-static/styles/overrides/_tabler.scss b/netbox/project-static/styles/overrides/_tabler.scss index a5ae3c647..f855daf0c 100644 --- a/netbox/project-static/styles/overrides/_tabler.scss +++ b/netbox/project-static/styles/overrides/_tabler.scss @@ -9,6 +9,10 @@ pre { // Tabler sets display: flex display: inline-block; } +.btn-sm { + // $border-radius-sm (2px) is too small + border-radius: $border-radius; +} // Tabs .nav-tabs { diff --git a/netbox/project-static/styles/transitional/_tables.scss b/netbox/project-static/styles/transitional/_tables.scss index 6ac17c59c..0af11f9cd 100644 --- a/netbox/project-static/styles/transitional/_tables.scss +++ b/netbox/project-static/styles/transitional/_tables.scss @@ -23,7 +23,6 @@ table.attr-table { // Restyle row header th { - color: $gray-700; font-weight: normal; width: min-content; } diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index b7d4f6fc6..1c58047ef 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -70,10 +70,5 @@ {# User messages #} {% include 'inc/messages.html' %} - {# Data container #} -

- {% block data %}{% endblock %} -
- diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 97be5e839..b28ee6e31 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -163,7 +163,7 @@
- diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 752fe6913..e50380ba0 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -11,13 +11,11 @@ {% trans "View List" %} -
- -
+
{% trans "Front" %} {% trans "Rear" %} diff --git a/netbox/templates/inc/toast.html b/netbox/templates/inc/toast.html index 85eff2d7a..0cf04b93b 100644 --- a/netbox/templates/inc/toast.html +++ b/netbox/templates/inc/toast.html @@ -1,6 +1,6 @@ {% load helpers %} -