diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 6e5bcfc23..5e8322657 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -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 = [
ORGANIZATION_MENU,
@@ -358,6 +371,7 @@ MENUS = [
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
+ ADMIN_MENU,
]
#
diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py
index 4ae9df89a..35353b4a3 100644
--- a/netbox/users/filtersets.py
+++ b/netbox/users/filtersets.py
@@ -1,10 +1,12 @@
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.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 ObjectPermission, Token, NetBoxUser
__all__ = (
'GroupFilterSet',
@@ -47,7 +49,7 @@ class UserFilterSet(BaseFilterSet):
)
class Meta:
- model = User
+ model = NetBoxUser
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
def search(self, queryset, name, value):
diff --git a/netbox/users/forms.py b/netbox/users/forms.py
deleted file mode 100644
index 027fa5327..000000000
--- a/netbox/users/forms.py
+++ /dev/null
@@ -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}
' if preference.description else ''
- help_text = f'{description}{field_name}
'
- 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: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64
'),
- )
-
- 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']
diff --git a/netbox/users/migrations/0004_netboxuser.py b/netbox/users/migrations/0004_netboxuser.py
new file mode 100644
index 000000000..03355ea3c
--- /dev/null
+++ b/netbox/users/migrations/0004_netboxuser.py
@@ -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()),
+ ],
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 4e7d9ca52..6b5b13b14 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -20,6 +20,7 @@ from utilities.utils import flatten_dict
from .constants import *
__all__ = (
+ 'NetBoxUser',
'ObjectPermission',
'Token',
'UserConfig',
@@ -30,6 +31,7 @@ __all__ = (
# Proxy models for admin
#
+
class AdminGroup(Group):
"""
Proxy contrib.auth.models.Group for the admin UI
@@ -48,10 +50,19 @@ class AdminUser(User):
proxy = True
+class NetBoxUser(User):
+ """
+ Proxy contrib.auth.models.User for the UI
+ """
+ class Meta:
+ verbose_name = 'User'
+ proxy = True
+
#
# User preferences
#
+
class UserConfig(models.Model):
"""
This model stores arbitrary user-specific preferences in a JSON data structure.
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index 0f1484887..f7c27ff63 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -1,8 +1,11 @@
+import django_tables2 as tables
from .models import Token
from netbox.tables import NetBoxTable, columns
+from users.models import NetBoxUser
__all__ = (
'TokenTable',
+ 'UserTable',
)
@@ -50,3 +53,17 @@ class TokenTable(NetBoxTable):
fields = (
'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')
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index ed1c21c02..03c1bf9b4 100644
--- a/netbox/users/urls.py
+++ b/netbox/users/urls.py
@@ -11,6 +11,14 @@ urlpatterns = [
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
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//', include(get_model_urls('users', 'netboxuser'))),
+
# API tokens
path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
diff --git a/netbox/users/views.py b/netbox/users/views.py
index a82620914..5c0dc1af3 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -2,7 +2,7 @@ import logging
from django.conf import settings
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.models import update_last_login
from django.contrib.auth.signals import user_logged_in
@@ -19,11 +19,11 @@ from extras.models import ObjectChange
from extras.tables import ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
+from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.views import register_model_view
-from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
-from .models import Token, UserConfig
-from .tables import TokenTable
+from . import filtersets, forms, tables
+from .models import Token, UserConfig, NetBoxUser
#
@@ -69,7 +69,7 @@ class LoginView(View):
return auth_backends
def get(self, request):
- form = LoginForm(request)
+ form = forms.LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
@@ -82,7 +82,7 @@ class LoginView(View):
def post(self, request):
logger = logging.getLogger('netbox.auth.login')
- form = LoginForm(request, data=request.POST)
+ form = forms.LoginForm(request, data=request.POST)
if form.is_valid():
logger.debug("Login form validation was successful")
@@ -175,7 +175,7 @@ class UserConfigView(LoginRequiredMixin, View):
def get(self, request):
userconfig = request.user.config
- form = UserConfigForm(instance=userconfig)
+ form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, {
'form': form,
@@ -184,7 +184,7 @@ class UserConfigView(LoginRequiredMixin, View):
def post(self, request):
userconfig = request.user.config
- form = UserConfigForm(request.POST, instance=userconfig)
+ form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid():
form.save()
@@ -207,7 +207,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('users:profile')
- form = PasswordChangeForm(user=request.user)
+ form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, {
'form': form,
@@ -215,7 +215,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
})
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():
form.save()
update_session_auth_hash(request, form.user)
@@ -237,7 +237,7 @@ class TokenListView(LoginRequiredMixin, View):
def get(self, request):
tokens = Token.objects.filter(user=request.user)
- table = TokenTable(tokens)
+ table = tables.TokenTable(tokens)
table.configure(request)
return render(request, 'users/api_tokens.html', {
@@ -257,7 +257,7 @@ class TokenEditView(LoginRequiredMixin, View):
else:
token = Token(user=request.user)
- form = TokenForm(instance=token)
+ form = forms.TokenForm(instance=token)
return render(request, 'generic/object_edit.html', {
'object': token,
@@ -269,10 +269,10 @@ class TokenEditView(LoginRequiredMixin, View):
if 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:
token = Token(user=request.user)
- form = TokenForm(request.POST)
+ form = forms.TokenForm(request.POST)
if form.is_valid():
@@ -333,3 +333,48 @@ class TokenDeleteView(LoginRequiredMixin, View):
'form': form,
'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
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 43ca9a589..a45e149c7 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -5,6 +5,7 @@ from django.urls.exceptions import NoReverseMatch
from netbox.registry import registry
from .permissions import resolve_permission
+from .querysets import RestrictedQuerySet
__all__ = (
'ContentTypePermissionRequiredMixin',
@@ -93,7 +94,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
'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 super().dispatch(request, *args, **kwargs)