12795 custom user model (#15005)

* 12795 users.User migration

* 12795 users.User migration

* 12795 review changes

* 12795 fix user model registration

* 12795 fix user model registration

* 12795 update migration

* 12795 update migration

* 12795 update migration

* 12795 add comment to migration db_table

* Tweak import to avoid class name collision

* 12795 add comment for _register_features requirement

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2024-02-05 10:24:03 -08:00 committed by GitHub
parent 5d9311eecf
commit 317bef6796
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 132 additions and 70 deletions

View File

@ -377,19 +377,19 @@ ADMIN_MENU = Menu(
items=( items=(
# Proxy model for auth.User # Proxy model for auth.User
MenuItem( MenuItem(
link=f'users:netboxuser_list', link=f'users:user_list',
link_text=_('Users'), link_text=_('Users'),
permissions=[f'auth.view_user'], permissions=[f'auth.view_user'],
staff_only=True, staff_only=True,
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:netboxuser_add', link=f'users:user_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_user'] permissions=[f'auth.add_user']
), ),
MenuItemButton( MenuItemButton(
link=f'users:netboxuser_import', link=f'users:user_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', icon_class='mdi mdi-upload',
permissions=[f'auth.add_user'] permissions=[f'auth.add_user']

View File

@ -455,6 +455,8 @@ AUTHENTICATION_BACKENDS = [
'netbox.authentication.ObjectPermissionBackend', 'netbox.authentication.ObjectPermissionBackend',
] ]
AUTH_USER_MODEL = 'users.User'
# Time zones # Time zones
USE_TZ = True USE_TZ = True
@ -595,6 +597,8 @@ for param in dir(configuration):
SOCIAL_AUTH_JSONFIELD_ENABLED = True SOCIAL_AUTH_JSONFIELD_ENABLED = True
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username' SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL
# #
# Django Prometheus # Django Prometheus
# #

View File

@ -25,7 +25,7 @@
<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.user_set.all %}
<a href="{% url 'users:netboxuser' 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>
{% endfor %} {% endfor %}

View File

@ -72,7 +72,7 @@
<h5 class="card-header">{% trans "Assigned Users" %}</h5> <h5 class="card-header">{% trans "Assigned Users" %}</h5>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for user in object.users.all %} {% for user in object.users.all %}
<a href="{% url 'users:netboxuser' 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>
{% endfor %} {% endfor %}

View File

@ -20,7 +20,7 @@
<tr> <tr>
<th scope="row">{% trans "User" %}</th> <th scope="row">{% trans "User" %}</th>
<td> <td>
<a href="{% url 'users:netboxuser' pk=object.user.pk %}">{{ object.user }}</a> <a href="{% url 'users:user' pk=object.user.pk %}">{{ object.user }}</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -1,6 +0,0 @@
from django.contrib import admin
from django.contrib.auth.models import Group, User
# Unregister Django's built-in Group and User admin views
admin.site.unregister(Group)
admin.site.unregister(User)

View File

@ -6,3 +6,14 @@ class UsersConfig(AppConfig):
def ready(self): def ready(self):
import users.signals import users.signals
from .models import NetBoxGroup, ObjectPermission, Token, User, UserConfig
from netbox.models.features import _register_features
# have to register these manually as the signal handler for class_prepared does
# not get registered until after these models are loaded. Any models defined in
# users.models should be registered here.
_register_features(NetBoxGroup)
_register_features(ObjectPermission)
_register_features(Token)
_register_features(User)
_register_features(UserConfig)

View File

@ -17,7 +17,7 @@ __all__ = (
class UserBulkEditForm(forms.Form): class UserBulkEditForm(forms.Form):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=NetBoxUser.objects.all(), queryset=User.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
first_name = forms.CharField( first_name = forms.CharField(
@ -46,7 +46,7 @@ class UserBulkEditForm(forms.Form):
label=_('Superuser status') label=_('Superuser status')
) )
model = NetBoxUser model = User
fieldsets = ( fieldsets = (
(None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')), (None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')),
) )

View File

@ -23,7 +23,7 @@ class GroupImportForm(CSVModelForm):
class UserImportForm(CSVModelForm): class UserImportForm(CSVModelForm):
class Meta: class Meta:
model = NetBoxUser model = User
fields = ( fields = (
'username', 'first_name', 'last_name', 'email', 'password', 'is_staff', 'username', 'first_name', 'last_name', 'email', 'password', 'is_staff',
'is_active', 'is_superuser' 'is_active', 'is_superuser'

View File

@ -5,7 +5,7 @@ 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, NetBoxUser, ObjectPermission, Token from users.models import NetBoxGroup, User, ObjectPermission, Token
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
@ -26,7 +26,7 @@ class GroupFilterForm(NetBoxModelFilterSetForm):
class UserFilterForm(NetBoxModelFilterSetForm): class UserFilterForm(NetBoxModelFilterSetForm):
model = NetBoxUser model = User
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id',)), (None, ('q', 'filter_id',)),
(_('Group'), ('group_id',)), (_('Group'), ('group_id',)),

View File

@ -198,7 +198,7 @@ class UserForm(forms.ModelForm):
) )
class Meta: class Meta:
model = NetBoxUser model = User
fields = [ fields = [
'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions', 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions',
'is_active', 'is_staff', 'is_superuser', 'is_active', 'is_staff', 'is_superuser',

View File

@ -4,6 +4,7 @@ import django.contrib.postgres.fields
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import users.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -31,6 +32,33 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('password', models.CharField(max_length=128)),
('last_login', models.DateTimeField(blank=True, null=True)),
('is_superuser', models.BooleanField(default=False)),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()])),
('first_name', models.CharField(blank=True, max_length=150)),
('last_name', models.CharField(blank=True, max_length=150)),
('email', models.EmailField(blank=True, max_length=254)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
('groups', models.ManyToManyField(blank=True, related_name='user_set', related_query_name='user', to='auth.group')),
('user_permissions', models.ManyToManyField(blank=True, related_name='user_set', related_query_name='user', to='auth.permission')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'auth_user',
'ordering': ('username',),
},
managers=[
('objects', users.models.UserManager()),
],
),
migrations.CreateModel( migrations.CreateModel(
name='UserConfig', name='UserConfig',
fields=[ fields=[

View File

@ -59,20 +59,4 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.GroupManager()), ('objects', django.contrib.auth.models.GroupManager()),
], ],
), ),
migrations.CreateModel(
name='NetBoxUser',
fields=[
],
options={
'verbose_name': 'User',
'proxy': True,
'indexes': [],
'constraints': [],
'ordering': ('username',),
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
] ]

View File

@ -0,0 +1,40 @@
# Generated by Django 5.0.1 on 2024-01-31 23:18
from django.db import migrations
def update_content_types(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
# Delete the new ContentTypes effected by the new models in the users app
ContentType.objects.filter(app_label='users', model='user').delete()
# Update the app labels of the original ContentTypes for auth.User to ensure
# that any foreign key references are preserved
ContentType.objects.filter(app_label='auth', model='user').update(app_label='users')
netboxuser_ct = ContentType.objects.filter(app_label='users', model='netboxuser').first()
if netboxuser_ct:
user_ct = ContentType.objects.filter(app_label='users', model='user').first()
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(object_type_id=netboxuser_ct.id).update(object_type_id=user_ct.id)
netboxuser_ct.delete()
class Migration(migrations.Migration):
dependencies = [
('users', '0002_squashed_0004'),
]
operations = [
# 0001_squashed had model with db_table=auth_user - now we switch it
# to None to use the default Django resolution (users.user)
migrations.AlterModelTable(
name='user',
table=None,
),
migrations.RunPython(
code=update_content_types,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -2,7 +2,10 @@ import binascii
import os import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, GroupManager, User, UserManager from django.contrib.auth import get_user_model
from django.contrib.auth.models import (
AbstractUser, Group, GroupManager, 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
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
@ -23,9 +26,9 @@ from .constants import *
__all__ = ( __all__ = (
'NetBoxGroup', 'NetBoxGroup',
'NetBoxUser',
'ObjectPermission', 'ObjectPermission',
'Token', 'Token',
'User',
'UserConfig', 'UserConfig',
) )
@ -34,7 +37,7 @@ __all__ = (
# Proxies for Django's User and Group models # Proxies for Django's User and Group models
# #
class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)): class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)):
pass pass
@ -42,20 +45,19 @@ class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
pass pass
class NetBoxUser(User): class User(AbstractUser):
""" """
Proxy contrib.auth.models.User for the UI Proxy contrib.auth.models.User for the UI
""" """
objects = NetBoxUserManager() objects = UserManager()
class Meta: class Meta:
proxy = True
ordering = ('username',) ordering = ('username',)
verbose_name = _('user') verbose_name = _('user')
verbose_name_plural = _('users') verbose_name_plural = _('users')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('users:netboxuser', args=[self.pk]) return reverse('users:user', args=[self.pk])
def clean(self): def clean(self):
super().clean() super().clean()
@ -91,7 +93,7 @@ class UserConfig(models.Model):
This model stores arbitrary user-specific preferences in a JSON data structure. This model stores arbitrary user-specific preferences in a JSON data structure.
""" """
user = models.OneToOneField( user = models.OneToOneField(
to=User, to=get_user_model(),
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='config' related_name='config'
) )
@ -220,7 +222,6 @@ class UserConfig(models.Model):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
@receiver(post_save, sender=NetBoxUser)
def create_userconfig(instance, created, raw=False, **kwargs): def create_userconfig(instance, created, raw=False, **kwargs):
""" """
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture. Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
@ -240,7 +241,7 @@ class Token(models.Model):
It also supports setting an expiration time and toggling write ability. It also supports setting an expiration time and toggling write ability.
""" """
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=get_user_model(),
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='tokens' related_name='tokens'
) )
@ -364,7 +365,7 @@ class ObjectPermission(models.Model):
related_name='object_permissions' related_name='object_permissions'
) )
users = models.ManyToManyField( users = models.ManyToManyField(
to=User, to=get_user_model(),
blank=True, blank=True,
related_name='object_permissions' related_name='object_permissions'
) )

View File

@ -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, NetBoxUser, ObjectPermission, Token from users.models import NetBoxGroup, User, ObjectPermission, Token
__all__ = ( __all__ = (
'GroupTable', 'GroupTable',
@ -49,7 +49,7 @@ class UserTable(NetBoxTable):
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = NetBoxUser model = User
fields = ( fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
'is_superuser', 'last_login', 'is_superuser', 'last_login',
@ -103,7 +103,7 @@ class ObjectPermissionTable(NetBoxTable):
) )
users = columns.ManyToManyColumn( users = columns.ManyToManyColumn(
verbose_name=_('Users'), verbose_name=_('Users'),
linkify_item=('users:netboxuser', {'pk': tables.A('pk')}) linkify_item=('users:user', {'pk': tables.A('pk')})
) )
groups = columns.ManyToManyColumn( groups = columns.ManyToManyColumn(
verbose_name=_('Groups'), verbose_name=_('Groups'),

View File

@ -15,7 +15,7 @@ class UserTestCase(
ViewTestCases.BulkEditObjectsViewTestCase, ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase, ViewTestCases.BulkDeleteObjectsViewTestCase,
): ):
model = NetBoxUser model = User
maxDiff = None maxDiff = None
validation_excluded_fields = ['password'] validation_excluded_fields = ['password']
@ -27,11 +27,11 @@ class UserTestCase(
def setUpTestData(cls): def setUpTestData(cls):
users = ( users = (
NetBoxUser(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'), User(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'),
NetBoxUser(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'), User(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'),
NetBoxUser(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'), User(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'),
) )
NetBoxUser.objects.bulk_create(users) User.objects.bulk_create(users)
cls.form_data = { cls.form_data = {
'username': 'usernamex', 'username': 'usernamex',

View File

@ -15,12 +15,12 @@ urlpatterns = [
path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))), path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
# Users # Users
path('users/', views.UserListView.as_view(), name='netboxuser_list'), path('users/', views.UserListView.as_view(), name='user_list'),
path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'), path('users/add/', views.UserEditView.as_view(), name='user_add'),
path('users/edit/', views.UserBulkEditView.as_view(), name='netboxuser_bulk_edit'), path('users/edit/', views.UserBulkEditView.as_view(), name='user_bulk_edit'),
path('users/import/', views.UserBulkImportView.as_view(), name='netboxuser_import'), path('users/import/', views.UserBulkImportView.as_view(), name='user_import'),
path('users/delete/', views.UserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'), path('users/delete/', views.UserBulkDeleteView.as_view(), name='user_bulk_delete'),
path('users/<int:pk>/', include(get_model_urls('users', 'netboxuser'))), path('users/<int:pk>/', include(get_model_urls('users', 'user'))),
# Groups # Groups
path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'), path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'),

View File

@ -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, NetBoxUser, ObjectPermission, Token from .models import NetBoxGroup, User, ObjectPermission, Token
# #
@ -56,15 +56,15 @@ class TokenBulkDeleteView(generic.BulkDeleteView):
# #
class UserListView(generic.ObjectListView): class UserListView(generic.ObjectListView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet
filterset_form = forms.UserFilterForm filterset_form = forms.UserFilterForm
table = tables.UserTable table = tables.UserTable
@register_model_view(NetBoxUser) @register_model_view(User)
class UserView(generic.ObjectView): class UserView(generic.ObjectView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
template_name = 'users/user.html' template_name = 'users/user.html'
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
@ -76,31 +76,31 @@ class UserView(generic.ObjectView):
} }
@register_model_view(NetBoxUser, 'edit') @register_model_view(User, 'edit')
class UserEditView(generic.ObjectEditView): class UserEditView(generic.ObjectEditView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
form = forms.UserForm form = forms.UserForm
@register_model_view(NetBoxUser, 'delete') @register_model_view(User, 'delete')
class UserDeleteView(generic.ObjectDeleteView): class UserDeleteView(generic.ObjectDeleteView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
class UserBulkEditView(generic.BulkEditView): class UserBulkEditView(generic.BulkEditView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet
table = tables.UserTable table = tables.UserTable
form = forms.UserBulkEditForm form = forms.UserBulkEditForm
class UserBulkImportView(generic.BulkImportView): class UserBulkImportView(generic.BulkImportView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
model_form = forms.UserImportForm model_form = forms.UserImportForm
class UserBulkDeleteView(generic.BulkDeleteView): class UserBulkDeleteView(generic.BulkDeleteView):
queryset = NetBoxUser.objects.all() queryset = User.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet
table = tables.UserTable table = tables.UserTable