1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

#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
This commit is contained in:
Jeremy Stretch
2024-03-04 08:29:53 -05:00
committed by GitHub
parent 709eac6b98
commit c6a3fc2407
26 changed files with 208 additions and 119 deletions

View File

@ -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,
)

View File

@ -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']

View File

@ -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

View File

@ -24,7 +24,7 @@
<div class="card">
<h5 class="card-header">{% trans "Users" %}</h5>
<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>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>

View File

@ -82,7 +82,7 @@
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% 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 %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}

View File

@ -53,7 +53,7 @@
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% 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 %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}

View File

@ -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',

View File

@ -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 *

View File

@ -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

View File

@ -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',

View File

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

View File

@ -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',)),
)

View File

@ -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

View File

@ -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):

View File

@ -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__ = (

View File

@ -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

View File

@ -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',
),
]

View File

@ -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'
)

View File

@ -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'),

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -23,11 +23,11 @@ urlpatterns = [
path('users/<int:pk>/', 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/<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
path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),

View File

@ -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

View File

@ -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}'"
)

View File

@ -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}'