125890 first working user list

This commit is contained in:
Arthur 2023-06-02 09:57:15 -07:00
parent 4208b79514
commit 773a4f9896
9 changed files with 143 additions and 147 deletions

View File

@ -344,6 +344,19 @@ OPERATIONS_MENU = Menu(
), ),
) )
ADMIN_MENU = Menu(
label=_('Admin'),
icon_class='mdi mdi-account-multiple',
groups=(
MenuGroup(
label=_('Users'),
items=(
get_model_item('users', 'user', _('Users'), actions=['add']),
),
),
),
)
MENUS = [ MENUS = [
ORGANIZATION_MENU, ORGANIZATION_MENU,
@ -358,6 +371,7 @@ MENUS = [
PROVISIONING_MENU, PROVISIONING_MENU,
CUSTOMIZATION_MENU, CUSTOMIZATION_MENU,
OPERATIONS_MENU, OPERATIONS_MENU,
ADMIN_MENU,
] ]
# #

View File

@ -1,10 +1,12 @@
import django_filters import django_filters
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
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 ObjectPermission, Token, NetBoxUser
__all__ = ( __all__ = (
'GroupFilterSet', 'GroupFilterSet',
@ -47,7 +49,7 @@ class UserFilterSet(BaseFilterSet):
) )
class Meta: class Meta:
model = User model = NetBoxUser
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
def search(self, queryset, name, value): def search(self, queryset, name, value):

View File

@ -1,130 +0,0 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.html import mark_safe
from django.utils.translation import gettext as _
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.preferences import PREFERENCES
from utilities.forms import BootstrapMixin
from utilities.forms.widgets import DateTimePicker
from utilities.utils import flatten_dict
from .models import Token, UserConfig
class LoginForm(BootstrapMixin, AuthenticationForm):
pass
class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
pass
class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported user preference
preference_fields = {}
for field_name, preference in PREFERENCES.items():
description = f'{preference.description}<br />' if preference.description else ''
help_text = f'{description}<code>{field_name}</code>'
field_kwargs = {
'label': preference.label,
'choices': preference.choices,
'help_text': mark_safe(help_text),
'coerce': preference.coerce,
'required': False,
'widget': forms.Select,
}
preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
attrs.update(preference_fields)
return super().__new__(mcs, name, bases, attrs)
class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
fieldsets = (
('User Interface', (
'pagination.per_page',
'pagination.placement',
'ui.colormode',
)),
('Miscellaneous', (
'data_format',
)),
)
# List of clearable preferences
pk = forms.MultipleChoiceField(
choices=[],
required=False
)
class Meta:
model = UserConfig
fields = ()
def __init__(self, *args, instance=None, **kwargs):
# Get initial data from UserConfig instance
initial_data = flatten_dict(instance.data)
kwargs['initial'] = initial_data
super().__init__(*args, instance=instance, **kwargs)
# Compile clearable preference choices
self.fields['pk'].choices = (
(f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
)
def save(self, *args, **kwargs):
# Set UserConfig data
for pref_name, value in self.cleaned_data.items():
if pref_name == 'pk':
continue
self.instance.set(pref_name, value, commit=False)
# Clear selected preferences
for preference in self.cleaned_data['pk']:
self.instance.clear(preference)
return super().save(*args, **kwargs)
@property
def plugin_fields(self):
return [
name for name in self.fields.keys() if name.startswith('plugins.')
]
class TokenForm(BootstrapMixin, forms.ModelForm):
key = forms.CharField(
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False,
label=_('Allowed IPs'),
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'),
)
class Meta:
model = Token
fields = [
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Omit the key field if token retrieval is not permitted
if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
del self.fields['key']

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.9 on 2023-06-02 16:55
import django.contrib.auth.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('users', '0003_token_allowed_ips_last_used'),
]
operations = [
migrations.CreateModel(
name='NetBoxUser',
fields=[],
options={
'verbose_name': 'User',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('auth.user',),
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -20,6 +20,7 @@ from utilities.utils import flatten_dict
from .constants import * from .constants import *
__all__ = ( __all__ = (
'NetBoxUser',
'ObjectPermission', 'ObjectPermission',
'Token', 'Token',
'UserConfig', 'UserConfig',
@ -30,6 +31,7 @@ __all__ = (
# Proxy models for admin # Proxy models for admin
# #
class AdminGroup(Group): class AdminGroup(Group):
""" """
Proxy contrib.auth.models.Group for the admin UI Proxy contrib.auth.models.Group for the admin UI
@ -48,10 +50,19 @@ class AdminUser(User):
proxy = True proxy = True
class NetBoxUser(User):
"""
Proxy contrib.auth.models.User for the UI
"""
class Meta:
verbose_name = 'User'
proxy = True
# #
# User preferences # User preferences
# #
class UserConfig(models.Model): 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.

View File

@ -1,8 +1,11 @@
import django_tables2 as tables
from .models import Token from .models import Token
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from users.models import NetBoxUser
__all__ = ( __all__ = (
'TokenTable', 'TokenTable',
'UserTable',
) )
@ -50,3 +53,17 @@ class TokenTable(NetBoxTable):
fields = ( fields = (
'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
) )
class UserTable(NetBoxTable):
username = tables.Column()
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(NetBoxTable.Meta):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'email', 'first_name', 'last_name'
)
default_columns = ('pk', 'username', 'email', 'first_name', 'last_name')

View File

@ -11,6 +11,14 @@ urlpatterns = [
path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('password/', views.ChangePasswordView.as_view(), name='change_password'),
# Users
path('users/', views.NetBoxUserListView.as_view(), name='user_list'),
path('users/add/', views.NetBoxUserEditView.as_view(), name='user_add'),
path('users/import/', views.NetBoxUserBulkImportView.as_view(), name='netboxuser_import'),
path('users/edit/', views.NetBoxUserBulkEditView.as_view(), name='netboxuser_bulk_edit'),
path('users/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'),
path('users/<int:pk>/', include(get_model_urls('users', 'netboxuser'))),
# API tokens # API tokens
path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),

View File

@ -2,7 +2,7 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash, get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
@ -19,11 +19,11 @@ from extras.models import ObjectChange
from extras.tables import ObjectChangeTable from extras.tables import ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import register_model_view from utilities.views import register_model_view
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from . import filtersets, forms, tables
from .models import Token, UserConfig from .models import Token, UserConfig, NetBoxUser
from .tables import TokenTable
# #
@ -69,7 +69,7 @@ class LoginView(View):
return auth_backends return auth_backends
def get(self, request): def get(self, request):
form = LoginForm(request) form = forms.LoginForm(request)
if request.user.is_authenticated: if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login') logger = logging.getLogger('netbox.auth.login')
@ -82,7 +82,7 @@ class LoginView(View):
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.auth.login') logger = logging.getLogger('netbox.auth.login')
form = LoginForm(request, data=request.POST) form = forms.LoginForm(request, data=request.POST)
if form.is_valid(): if form.is_valid():
logger.debug("Login form validation was successful") logger.debug("Login form validation was successful")
@ -175,7 +175,7 @@ class UserConfigView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
userconfig = request.user.config userconfig = request.user.config
form = UserConfigForm(instance=userconfig) form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -184,7 +184,7 @@ class UserConfigView(LoginRequiredMixin, View):
def post(self, request): def post(self, request):
userconfig = request.user.config userconfig = request.user.config
form = UserConfigForm(request.POST, instance=userconfig) form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -207,7 +207,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('users:profile') return redirect('users:profile')
form = PasswordChangeForm(user=request.user) form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -215,7 +215,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
}) })
def post(self, request): def post(self, request):
form = PasswordChangeForm(user=request.user, data=request.POST) form = forms.PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
update_session_auth_hash(request, form.user) update_session_auth_hash(request, form.user)
@ -237,7 +237,7 @@ class TokenListView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
tokens = Token.objects.filter(user=request.user) tokens = Token.objects.filter(user=request.user)
table = TokenTable(tokens) table = tables.TokenTable(tokens)
table.configure(request) table.configure(request)
return render(request, 'users/api_tokens.html', { return render(request, 'users/api_tokens.html', {
@ -257,7 +257,7 @@ class TokenEditView(LoginRequiredMixin, View):
else: else:
token = Token(user=request.user) token = Token(user=request.user)
form = TokenForm(instance=token) form = forms.TokenForm(instance=token)
return render(request, 'generic/object_edit.html', { return render(request, 'generic/object_edit.html', {
'object': token, 'object': token,
@ -269,10 +269,10 @@ class TokenEditView(LoginRequiredMixin, View):
if pk: if pk:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = TokenForm(request.POST, instance=token) form = forms.TokenForm(request.POST, instance=token)
else: else:
token = Token(user=request.user) token = Token(user=request.user)
form = TokenForm(request.POST) form = forms.TokenForm(request.POST)
if form.is_valid(): if form.is_valid():
@ -333,3 +333,48 @@ class TokenDeleteView(LoginRequiredMixin, View):
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:token_list'),
}) })
#
# Users
#
class NetBoxUserListView(generic.ObjectListView):
queryset = NetBoxUser.objects.all()
filterset = filtersets.UserFilterSet
filterset_form = forms.UserFilterForm
table = tables.UserTable
@register_model_view(get_user_model())
class NetBoxUserView(generic.ObjectView):
queryset = get_user_model().objects.all()
@register_model_view(NetBoxUser, 'edit')
class NetBoxUserEditView(generic.ObjectEditView):
queryset = get_user_model().objects.all()
form = forms.UserForm
@register_model_view(NetBoxUser, 'delete')
class NetBoxUserDeleteView(generic.ObjectDeleteView):
queryset = get_user_model().objects.all()
class NetBoxUserBulkImportView(generic.BulkImportView):
queryset = get_user_model().objects.all()
model_form = forms.UserImportForm
class NetBoxUserBulkEditView(generic.BulkEditView):
queryset = get_user_model().objects.all()
filterset = filtersets.UserFilterSet
table = tables.UserTable
form = forms.UserBulkEditForm
class NetBoxUserBulkDeleteView(generic.BulkDeleteView):
queryset = get_user_model().objects.all()
filterset = filtersets.UserFilterSet
table = tables.UserTable

View File

@ -5,6 +5,7 @@ from django.urls.exceptions import NoReverseMatch
from netbox.registry import registry from netbox.registry import registry
from .permissions import resolve_permission from .permissions import resolve_permission
from .querysets import RestrictedQuerySet
__all__ = ( __all__ = (
'ContentTypePermissionRequiredMixin', 'ContentTypePermissionRequiredMixin',
@ -93,7 +94,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
'a base queryset'.format(self.__class__.__name__) 'a base queryset'.format(self.__class__.__name__)
) )
if not self.has_permission(): if isinstance(self.queryset, RestrictedQuerySet) and not self.has_permission():
return self.handle_no_permission() return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)