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)