From 773a4f9896b540a5dfd27798714761f31db53a99 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 2 Jun 2023 09:57:15 -0700 Subject: [PATCH 01/72] 125890 first working user list --- netbox/netbox/navigation/menu.py | 14 +++ netbox/users/filtersets.py | 6 +- netbox/users/forms.py | 130 --------------------- netbox/users/migrations/0004_netboxuser.py | 28 +++++ netbox/users/models.py | 11 ++ netbox/users/tables.py | 17 +++ netbox/users/urls.py | 8 ++ netbox/users/views.py | 73 +++++++++--- netbox/utilities/views.py | 3 +- 9 files changed, 143 insertions(+), 147 deletions(-) delete mode 100644 netbox/users/forms.py create mode 100644 netbox/users/migrations/0004_netboxuser.py diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6e5bcfc23f4..5e83226578e 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -344,6 +344,19 @@ ), ) +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 @@ PROVISIONING_MENU, CUSTOMIZATION_MENU, OPERATIONS_MENU, + ADMIN_MENU, ] # diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4ae9df89a9f..35353b4a3f3 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 027fa532779..00000000000 --- 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 00000000000..03355ea3c16 --- /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 4e7d9ca523a..6b5b13b1417 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -20,6 +20,7 @@ from .constants import * __all__ = ( + 'NetBoxUser', 'ObjectPermission', 'Token', 'UserConfig', @@ -30,6 +31,7 @@ # Proxy models for admin # + class AdminGroup(Group): """ Proxy contrib.auth.models.Group for the admin UI @@ -48,10 +50,19 @@ class Meta: 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 0f14848875d..f7c27ff6346 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 Meta(NetBoxTable.Meta): 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 ed1c21c024b..03c1bf9b4c9 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -11,6 +11,14 @@ 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 a82620914ad..5c0dc1af37b 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -2,7 +2,7 @@ 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.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 @@ def get_auth_backends(self, request): 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 @@ def get(self, request): 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 @@ def get(self, request): 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 @@ def get(self, request): 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 @@ def get(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(): 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 @@ def get(self, request, pk=None): 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 @@ def post(self, request, pk=None): 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 @@ def post(self, request, pk): '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 43ca9a58981..a45e149c779 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -5,6 +5,7 @@ from netbox.registry import registry from .permissions import resolve_permission +from .querysets import RestrictedQuerySet __all__ = ( 'ContentTypePermissionRequiredMixin', @@ -93,7 +94,7 @@ def dispatch(self, request, *args, **kwargs): '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) From b17dfa05345fce3505aa928a748bc8897d618023 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 2 Jun 2023 09:58:45 -0700 Subject: [PATCH 02/72] 125890 first working user list --- netbox/users/forms/__init__.py | 4 + netbox/users/forms/bulk_edit.py | 38 ++++++++ netbox/users/forms/bulk_import.py | 24 +++++ netbox/users/forms/filtersets.py | 55 +++++++++++ netbox/users/forms/model_forms.py | 153 ++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 netbox/users/forms/__init__.py create mode 100644 netbox/users/forms/bulk_edit.py create mode 100644 netbox/users/forms/bulk_import.py create mode 100644 netbox/users/forms/filtersets.py create mode 100644 netbox/users/forms/model_forms.py diff --git a/netbox/users/forms/__init__.py b/netbox/users/forms/__init__.py new file mode 100644 index 00000000000..1499f98b281 --- /dev/null +++ b/netbox/users/forms/__init__.py @@ -0,0 +1,4 @@ +from .bulk_edit import * +from .bulk_import import * +from .filtersets import * +from .model_forms import * diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py new file mode 100644 index 00000000000..dd2dc6aa9d7 --- /dev/null +++ b/netbox/users/forms/bulk_edit.py @@ -0,0 +1,38 @@ +from django import forms +from django.utils.translation import gettext as _ + +from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices +from circuits.models import * +from ipam.models import ASN +from netbox.forms import NetBoxModelBulkEditForm +from tenancy.models import Tenant +from utilities.forms import add_blank_choice +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.widgets import DatePicker, NumberWithOptions + +__all__ = ( + 'UserBulkEditForm', +) + + +class UserBulkEditForm(NetBoxModelBulkEditForm): + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + label=_('Comments') + ) + + model = Provider + fieldsets = ( + (None, ('asns', 'description')), + ) + nullable_fields = ( + 'asns', 'description', 'comments', + ) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py new file mode 100644 index 00000000000..aa3718d24d7 --- /dev/null +++ b/netbox/users/forms/bulk_import.py @@ -0,0 +1,24 @@ +from django import forms + +from circuits.choices import CircuitStatusChoices +from circuits.models import * +from dcim.models import Site +from django.utils.translation import gettext as _ +from netbox.forms import NetBoxModelImportForm +from tenancy.models import Tenant +from utilities.forms import BootstrapMixin +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField + +__all__ = ( + 'UserImportForm', +) + + +class UserImportForm(NetBoxModelImportForm): + slug = SlugField() + + class Meta: + model = Provider + fields = ( + 'name', 'slug', 'description', 'comments', 'tags', + ) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py new file mode 100644 index 00000000000..30b6bd83288 --- /dev/null +++ b/netbox/users/forms/filtersets.py @@ -0,0 +1,55 @@ +from django import forms +from django.utils.translation import gettext as _ + +from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices +from circuits.models import * +from dcim.models import Region, Site, SiteGroup +from ipam.models import ASN +from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm, ContactModelFilterForm +from users.models import NetBoxUser +from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.widgets import DatePicker, NumberWithOptions + +__all__ = ( + 'UserFilterForm', +) + + +class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): + model = NetBoxUser + fieldsets = ( + (None, ('q', 'filter_id',)), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('ASN', ('asn',)), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site') + ) + asn = forms.IntegerField( + required=False, + label=_('ASN (legacy)') + ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs') + ) + tag = TagFilterField(model) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py new file mode 100644 index 00000000000..265a66cc85d --- /dev/null +++ b/netbox/users/forms/model_forms.py @@ -0,0 +1,153 @@ +from django import forms +from django.conf import settings +from django.contrib.auth import get_user_model +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 users.models import * + + +__all__ = ( + 'LoginForm', + 'PasswordChangeForm', + 'TokenForm', + 'UserConfigForm', + 'UserForm', +) + + +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'] + + +class UserForm(BootstrapMixin, forms.ModelForm): + + fieldsets = ( + ('User', ('username', )), + ) + + class Meta: + model = NetBoxUser + fields = [ + 'username', + ] From a6094679683629ac209929c8bb8994836614ef9a Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Jun 2023 09:57:23 -0700 Subject: [PATCH 03/72] 125890 add form fields --- netbox/netbox/views/generic/mixins.py | 1 + netbox/users/forms/filtersets.py | 39 +++++++++++---------------- netbox/users/forms/model_forms.py | 13 +++++++-- netbox/users/tables.py | 7 ++--- netbox/users/urls.py | 1 + netbox/users/views.py | 15 ++++++++++- 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 8e363f0a574..a55f015094e 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -22,6 +22,7 @@ def get_permitted_actions(self, user, model=None): Return a tuple of actions for which the given user is permitted to do. """ model = model or self.queryset.model + return [ action for action in self.actions if user.has_perms([ get_permission_for_model(model, name) for name in self.action_perms[action] diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 30b6bd83288..473bddac9a6 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -20,36 +20,27 @@ class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = NetBoxUser fieldsets = ( (None, ('q', 'filter_id',)), - ('Location', ('region_id', 'site_group_id', 'site_id')), - ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role', 'contact_group')), + ('Name', ('username', 'first_name', 'last_name')), + ('Security', ('is_superuser', 'is_staff', 'is_active')), ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region') + username = forms.CharField( + required=False ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group') + first_name = forms.CharField( + required=False + ) + last_name = forms.CharField( + required=False ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), + is_superuser = forms.BooleanField( required=False, - query_params={ - 'region_id': '$region_id', - 'site_group_id': '$site_group_id', - }, - label=_('Site') + label='Is Superuser', ) - asn = forms.IntegerField( + is_staff = forms.BooleanField( required=False, - label=_('ASN (legacy)') + label='Is Staff', ) - asn_id = DynamicModelMultipleChoiceField( - queryset=ASN.objects.all(), + is_active = forms.BooleanField( required=False, - label=_('ASNs') + label='Is Active', ) - tag = TagFilterField(model) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 265a66cc85d..fbf55ffa8e9 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.auth.models import Group from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe from django.utils.translation import gettext as _ @@ -10,6 +11,7 @@ from ipam.validators import prefix_validator from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import DateTimePicker from utilities.utils import flatten_dict from users.models import * @@ -141,13 +143,20 @@ def __init__(self, *args, **kwargs): class UserForm(BootstrapMixin, forms.ModelForm): + groups = DynamicModelMultipleChoiceField( + queryset=Group.objects.all() + ) fieldsets = ( - ('User', ('username', )), + ('User', ('username', 'first_name', 'last_name', 'email', )), + ('Groups', ('groups', )), + ('Status', ('is_active', 'is_staff', 'is_superuser', )), + ('Important Dates', ('last_login', 'date_joined', )), ) class Meta: model = NetBoxUser fields = [ - 'username', + 'username', 'first_name', 'last_name', 'email', 'groups', + 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', ] diff --git a/netbox/users/tables.py b/netbox/users/tables.py index f7c27ff6346..45a87e7ac07 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,4 +1,5 @@ import django_tables2 as tables +from django_tables2.utils import A from .models import Token from netbox.tables import NetBoxTable, columns from users.models import NetBoxUser @@ -56,7 +57,7 @@ class Meta(NetBoxTable.Meta): class UserTable(NetBoxTable): - username = tables.Column() + username = tables.LinkColumn('users:netboxuser', args=[A('pk')]) actions = columns.ActionsColumn( actions=('edit', 'delete'), ) @@ -64,6 +65,6 @@ class UserTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = NetBoxUser fields = ( - 'pk', 'id', 'username', 'email', 'first_name', 'last_name' + 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ) - default_columns = ('pk', 'username', 'email', 'first_name', 'last_name') + default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 03c1bf9b4c9..12c4e6e667c 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -14,6 +14,7 @@ # Users path('users/', views.NetBoxUserListView.as_view(), name='user_list'), path('users/add/', views.NetBoxUserEditView.as_view(), name='user_add'), + path('users/add/', views.NetBoxUserEditView.as_view(), name='netboxuser_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'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 5c0dc1af37b..d114deb7a91 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -346,9 +346,22 @@ class NetBoxUserListView(generic.ObjectListView): table = tables.UserTable -@register_model_view(get_user_model()) +@register_model_view(NetBoxUser) class NetBoxUserView(generic.ObjectView): queryset = get_user_model().objects.all() + template_name = 'users/user.html' + + def get_extra_context(self, request, instance): + # Compile changelog table + changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related( + 'changed_object_type' + )[:20] + changelog_table = ObjectChangeTable(changelog) + + return { + 'changelog_table': changelog_table, + 'active_tab': 'user', + } @register_model_view(NetBoxUser, 'edit') From bd67cfb4014bd96cf297e830eff4d71dbf36735b Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Jun 2023 11:15:35 -0700 Subject: [PATCH 04/72] 125890 basic group objectpermission views --- netbox/netbox/navigation/menu.py | 4 +- netbox/users/forms/bulk_edit.py | 48 ++++++++ netbox/users/forms/bulk_import.py | 31 ++++- netbox/users/forms/filtersets.py | 62 ++++++++++ netbox/users/forms/model_forms.py | 30 +++++ ...user.py => 0004_netboxgroup_netboxuser.py} | 16 ++- netbox/users/models.py | 10 ++ netbox/users/tables.py | 30 +++++ netbox/users/urls.py | 19 +++- netbox/users/views.py | 106 ++++++++++++++++-- 10 files changed, 339 insertions(+), 17 deletions(-) rename netbox/users/migrations/{0004_netboxuser.py => 0004_netboxgroup_netboxuser.py} (59%) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 5e83226578e..b896f67ef4f 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -351,7 +351,9 @@ MenuGroup( label=_('Users'), items=( - get_model_item('users', 'user', _('Users'), actions=['add']), + get_model_item('users', 'netboxuser', _('Users'), actions=['add']), + get_model_item('users', 'netboxgroup', _('Groups'), actions=['add']), + get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), ), ), ), diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index dd2dc6aa9d7..c2b548e8d84 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -11,6 +11,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( + 'GroupBulkEditForm', + 'ObjectPermissionBulkEditForm', 'UserBulkEditForm', ) @@ -36,3 +38,49 @@ class UserBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ( 'asns', 'description', 'comments', ) + + +class GroupBulkEditForm(NetBoxModelBulkEditForm): + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + label=_('Comments') + ) + + model = Provider + fieldsets = ( + (None, ('asns', 'description')), + ) + nullable_fields = ( + 'asns', 'description', 'comments', + ) + + +class ObjectPermissionBulkEditForm(NetBoxModelBulkEditForm): + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + comments = CommentField( + label=_('Comments') + ) + + model = Provider + fieldsets = ( + (None, ('asns', 'description')), + ) + nullable_fields = ( + 'asns', 'description', 'comments', + ) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index aa3718d24d7..060d771ee3d 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -1,15 +1,14 @@ from django import forms -from circuits.choices import CircuitStatusChoices -from circuits.models import * -from dcim.models import Site +from users.models import * from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelImportForm -from tenancy.models import Tenant from utilities.forms import BootstrapMixin from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( + 'GroupImportForm', + 'ObjectPermissionImportForm', 'UserImportForm', ) @@ -18,7 +17,27 @@ class UserImportForm(NetBoxModelImportForm): slug = SlugField() class Meta: - model = Provider + model = NetBoxUser fields = ( - 'name', 'slug', 'description', 'comments', 'tags', + 'email', + ) + + +class GroupImportForm(NetBoxModelImportForm): + slug = SlugField() + + class Meta: + model = NetBoxGroup + fields = ( + 'name', + ) + + +class ObjectPermissionImportForm(NetBoxModelImportForm): + slug = SlugField() + + class Meta: + model = ObjectPermission + fields = ( + 'name', ) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 473bddac9a6..cef567230d3 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -12,6 +12,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( + 'GroupFilterForm', + 'ObjectPermissionFilterForm', 'UserFilterForm', ) @@ -44,3 +46,63 @@ class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): required=False, label='Is Active', ) + + +class GroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): + model = NetBoxUser + fieldsets = ( + (None, ('q', 'filter_id',)), + ('Name', ('username', 'first_name', 'last_name')), + ('Security', ('is_superuser', 'is_staff', 'is_active')), + ) + username = forms.CharField( + required=False + ) + first_name = forms.CharField( + required=False + ) + last_name = forms.CharField( + required=False + ) + is_superuser = forms.BooleanField( + required=False, + label='Is Superuser', + ) + is_staff = forms.BooleanField( + required=False, + label='Is Staff', + ) + is_active = forms.BooleanField( + required=False, + label='Is Active', + ) + + +class ObjectPermissionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): + model = NetBoxUser + fieldsets = ( + (None, ('q', 'filter_id',)), + ('Name', ('username', 'first_name', 'last_name')), + ('Security', ('is_superuser', 'is_staff', 'is_active')), + ) + username = forms.CharField( + required=False + ) + first_name = forms.CharField( + required=False + ) + last_name = forms.CharField( + required=False + ) + is_superuser = forms.BooleanField( + required=False, + label='Is Superuser', + ) + is_staff = forms.BooleanField( + required=False, + label='Is Staff', + ) + is_active = forms.BooleanField( + required=False, + label='Is Active', + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index fbf55ffa8e9..209e6b7daa0 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -18,7 +18,9 @@ __all__ = ( + 'GroupForm', 'LoginForm', + 'ObjectPermissionForm', 'PasswordChangeForm', 'TokenForm', 'UserConfigForm', @@ -160,3 +162,31 @@ class Meta: 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', ] + + +class GroupForm(BootstrapMixin, forms.ModelForm): + + fieldsets = ( + ('User', ('username', 'first_name', 'last_name', 'email', )), + ) + + class Meta: + model = NetBoxUser + fields = [ + 'username', 'first_name', 'last_name', 'email', 'groups', + 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', + ] + + +class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): + + fieldsets = ( + ('User', ('username', 'first_name', 'last_name', 'email', )), + ) + + class Meta: + model = NetBoxUser + fields = [ + 'username', 'first_name', 'last_name', 'email', 'groups', + 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', + ] diff --git a/netbox/users/migrations/0004_netboxuser.py b/netbox/users/migrations/0004_netboxgroup_netboxuser.py similarity index 59% rename from netbox/users/migrations/0004_netboxuser.py rename to netbox/users/migrations/0004_netboxgroup_netboxuser.py index 03355ea3c16..7bb746bd568 100644 --- a/netbox/users/migrations/0004_netboxuser.py +++ b/netbox/users/migrations/0004_netboxgroup_netboxuser.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.9 on 2023-06-02 16:55 +# Generated by Django 4.1.9 on 2023-06-06 18:15 import django.contrib.auth.models from django.db import migrations @@ -11,6 +11,20 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='NetBoxGroup', + fields=[], + options={ + 'verbose_name': 'Group', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), migrations.CreateModel( name='NetBoxUser', fields=[], diff --git a/netbox/users/models.py b/netbox/users/models.py index 6b5b13b1417..7bb0377e49e 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -20,6 +20,7 @@ from .constants import * __all__ = ( + 'NetBoxGroup', 'NetBoxUser', 'ObjectPermission', 'Token', @@ -58,6 +59,15 @@ class Meta: verbose_name = 'User' proxy = True + +class NetBoxGroup(Group): + """ + Proxy contrib.auth.models.User for the UI + """ + class Meta: + verbose_name = 'Group' + proxy = True + # # User preferences # diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 45a87e7ac07..4931d932743 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -5,6 +5,8 @@ from users.models import NetBoxUser __all__ = ( + 'GroupTable', + 'ObjectPermissionTable', 'TokenTable', 'UserTable', ) @@ -68,3 +70,31 @@ class Meta(NetBoxTable.Meta): 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ) default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') + + +class GroupTable(NetBoxTable): + username = tables.LinkColumn('users:netboxuser', args=[A('pk')]) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxUser + fields = ( + 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + ) + default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') + + +class ObjectPermissionTable(NetBoxTable): + username = tables.LinkColumn('users:netboxuser', args=[A('pk')]) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxUser + fields = ( + 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + ) + default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 12c4e6e667c..17e9b96852b 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -12,14 +12,29 @@ 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/', views.NetBoxUserListView.as_view(), name='netboxuser_list'), path('users/add/', views.NetBoxUserEditView.as_view(), name='netboxuser_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'))), + # Groups + path('groups/', views.NetBoxUserListView.as_view(), name='netboxgroup_list'), + path('groups/add/', views.NetBoxUserEditView.as_view(), name='netboxgroup_add'), + path('groups/import/', views.NetBoxUserBulkImportView.as_view(), name='netboxgroup_import'), + path('groups/edit/', views.NetBoxUserBulkEditView.as_view(), name='netboxgroup_bulk_edit'), + path('groups/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), + path('groups//', include(get_model_urls('users', 'netboxgroup'))), + + # Permissions + path('permissions/', views.NetBoxUserListView.as_view(), name='objectpermission_list'), + path('permissions/add/', views.NetBoxUserEditView.as_view(), name='objectpermission_add'), + path('permissions/import/', views.NetBoxUserBulkImportView.as_view(), name='objectpermission_import'), + path('permissions/edit/', views.NetBoxUserBulkEditView.as_view(), name='objectpermission_bulk_edit'), + path('permissions/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='objectpermission_bulk_delete'), + path('permissions//', include(get_model_urls('users', 'objectpermission'))), + # 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 d114deb7a91..3bf6d5e1c7f 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -23,7 +23,7 @@ from utilities.forms import ConfirmationForm from utilities.views import register_model_view from . import filtersets, forms, tables -from .models import Token, UserConfig, NetBoxUser +from .models import Token, UserConfig, NetBoxGroup, NetBoxUser, ObjectPermission # @@ -348,7 +348,7 @@ class NetBoxUserListView(generic.ObjectListView): @register_model_view(NetBoxUser) class NetBoxUserView(generic.ObjectView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() template_name = 'users/user.html' def get_extra_context(self, request, instance): @@ -366,28 +366,120 @@ def get_extra_context(self, request, instance): @register_model_view(NetBoxUser, 'edit') class NetBoxUserEditView(generic.ObjectEditView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() form = forms.UserForm @register_model_view(NetBoxUser, 'delete') class NetBoxUserDeleteView(generic.ObjectDeleteView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() class NetBoxUserBulkImportView(generic.BulkImportView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() model_form = forms.UserImportForm class NetBoxUserBulkEditView(generic.BulkEditView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() filterset = filtersets.UserFilterSet table = tables.UserTable form = forms.UserBulkEditForm class NetBoxUserBulkDeleteView(generic.BulkDeleteView): - queryset = get_user_model().objects.all() + queryset = NetBoxUser.objects.all() filterset = filtersets.UserFilterSet table = tables.UserTable + +# +# Groups +# + + +class NetBoxGroupListView(generic.ObjectListView): + queryset = NetBoxGroup.objects.all() + filterset = filtersets.GroupFilterSet + filterset_form = forms.GroupFilterForm + table = tables.GroupTable + + +@register_model_view(NetBoxGroup) +class NetBoxGroupView(generic.ObjectView): + queryset = NetBoxGroup.objects.all() + template_name = 'users/group.html' + + +@register_model_view(NetBoxGroup, 'edit') +class NetBoxGroupEditView(generic.ObjectEditView): + queryset = NetBoxGroup.objects.all() + form = forms.GroupForm + + +@register_model_view(NetBoxGroup, 'delete') +class NetBoxGroupDeleteView(generic.ObjectDeleteView): + queryset = NetBoxGroup.objects.all() + + +class NetBoxGroupBulkImportView(generic.BulkImportView): + queryset = NetBoxGroup.objects.all() + model_form = forms.GroupImportForm + + +class NetBoxGroupBulkEditView(generic.BulkEditView): + queryset = NetBoxGroup.objects.all() + filterset = filtersets.GroupFilterSet + table = tables.GroupTable + form = forms.GroupBulkEditForm + + +class NetBoxGroupBulkDeleteView(generic.BulkDeleteView): + queryset = NetBoxGroup.objects.all() + filterset = filtersets.GroupFilterSet + table = tables.GroupTable + +# +# ObjectPermissions +# + + +class ObjectPermissionListView(generic.ObjectListView): + queryset = NetBoxGroup.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + filterset_form = forms.ObjectPermissionFilterForm + table = tables.ObjectPermissionTable + + +@register_model_view(ObjectPermission) +class ObjectPermissionView(generic.ObjectView): + queryset = NetBoxGroup.objects.all() + template_name = 'users/objectpermission.html' + + +@register_model_view(ObjectPermission, 'edit') +class ObjectPermissionEditView(generic.ObjectEditView): + queryset = ObjectPermission.objects.all() + form = forms.ObjectPermissionForm + + +@register_model_view(ObjectPermission, 'delete') +class ObjectPermissionDeleteView(generic.ObjectDeleteView): + queryset = ObjectPermission.objects.all() + + +class ObjectPermissionBulkImportView(generic.BulkImportView): + queryset = ObjectPermission.objects.all() + model_form = forms.ObjectPermissionImportForm + + +class ObjectPermissionBulkEditView(generic.BulkEditView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + table = tables.ObjectPermissionTable + form = forms.ObjectPermissionBulkEditForm + + +class ObjectPermissionBulkDeleteView(generic.BulkDeleteView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + table = tables.ObjectPermissionTable From 4da0b835fdd1461baa1f3811e34d192e2b55f0fe Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Jun 2023 08:36:42 -0700 Subject: [PATCH 05/72] 125890 basic group objectpermission views --- netbox/users/forms/model_forms.py | 13 ++++++------- netbox/users/urls.py | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 209e6b7daa0..ea586965429 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -167,26 +167,25 @@ class Meta: class GroupForm(BootstrapMixin, forms.ModelForm): fieldsets = ( - ('User', ('username', 'first_name', 'last_name', 'email', )), + ('name', ), ) class Meta: - model = NetBoxUser + model = NetBoxGroup fields = [ - 'username', 'first_name', 'last_name', 'email', 'groups', - 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', + 'name', ] class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): fieldsets = ( + ('name', 'description', 'enabled'), ('User', ('username', 'first_name', 'last_name', 'email', )), ) class Meta: - model = NetBoxUser + model = ObjectPermission fields = [ - 'username', 'first_name', 'last_name', 'email', 'groups', - 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', + 'name', 'description', 'enabled', ] diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 17e9b96852b..caed08c45f3 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -20,19 +20,19 @@ path('users//', include(get_model_urls('users', 'netboxuser'))), # Groups - path('groups/', views.NetBoxUserListView.as_view(), name='netboxgroup_list'), - path('groups/add/', views.NetBoxUserEditView.as_view(), name='netboxgroup_add'), - path('groups/import/', views.NetBoxUserBulkImportView.as_view(), name='netboxgroup_import'), - path('groups/edit/', views.NetBoxUserBulkEditView.as_view(), name='netboxgroup_bulk_edit'), - path('groups/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), + path('groups/', views.NetBoxGroupListView.as_view(), name='netboxgroup_list'), + path('groups/add/', views.NetBoxGroupEditView.as_view(), name='netboxgroup_add'), + path('groups/import/', views.NetBoxGroupBulkImportView.as_view(), name='netboxgroup_import'), + path('groups/edit/', views.NetBoxGroupBulkEditView.as_view(), name='netboxgroup_bulk_edit'), + path('groups/delete/', views.NetBoxGroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), path('groups//', include(get_model_urls('users', 'netboxgroup'))), # Permissions - path('permissions/', views.NetBoxUserListView.as_view(), name='objectpermission_list'), - path('permissions/add/', views.NetBoxUserEditView.as_view(), name='objectpermission_add'), - path('permissions/import/', views.NetBoxUserBulkImportView.as_view(), name='objectpermission_import'), - path('permissions/edit/', views.NetBoxUserBulkEditView.as_view(), name='objectpermission_bulk_edit'), - path('permissions/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='objectpermission_bulk_delete'), + path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'), + path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'), + path('permissions/import/', views.ObjectPermissionBulkImportView.as_view(), name='objectpermission_import'), + path('permissions/edit/', views.ObjectPermissionBulkEditView.as_view(), name='objectpermission_bulk_edit'), + path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'), path('permissions//', include(get_model_urls('users', 'objectpermission'))), # API tokens From 2fdd834a6601c9c5b3496427fa31e7a2136b3631 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Jun 2023 11:05:31 -0700 Subject: [PATCH 06/72] 125890 fix group permission views --- netbox/templates/users/group.html | 43 +++++++++++ netbox/templates/users/objectpermission.html | 43 +++++++++++ netbox/templates/users/user.html | 81 ++++++++++++++++++++ netbox/users/filtersets.py | 2 +- netbox/users/forms/model_forms.py | 24 +++++- netbox/users/tables.py | 16 ++-- netbox/users/views.py | 13 +++- 7 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 netbox/templates/users/group.html create mode 100644 netbox/templates/users/objectpermission.html create mode 100644 netbox/templates/users/user.html diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html new file mode 100644 index 00000000000..ec5c0acad44 --- /dev/null +++ b/netbox/templates/users/group.html @@ -0,0 +1,43 @@ +{% extends 'users/base.html' %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}Group Detail{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
Account Details
+
+ + + + + +
Name{{ object.name }}
+
+
+
+
+
+
Assigned Groups
+
    + {% for group in request.user.groups.all %} +
  • {{ group }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html new file mode 100644 index 00000000000..4fdbb428a98 --- /dev/null +++ b/netbox/templates/users/objectpermission.html @@ -0,0 +1,43 @@ +{% extends 'users/base.html' %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}Permission Detail{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
Account Details
+
+ + + + + +
Name{{ object.name }}
+
+
+
+
+
+
Assigned Groups
+
    + {% for group in request.user.groups.all %} +
  • {{ group }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html new file mode 100644 index 00000000000..3b514b10351 --- /dev/null +++ b/netbox/templates/users/user.html @@ -0,0 +1,81 @@ +{% extends 'users/base.html' %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}User Detail{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
Account Details
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Username{{ object.username }}
Full Name + {% if object.first_name or object.last_name %} + {{ object.first_name }} {{ object.last_name }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Email{{ object.email|placeholder }}
Account Created{{ object.date_joined|annotated_date }}
Superuser{% checkmark object.is_superuser %}
Admin Access{% checkmark object.is_staff %}
+
+
+
+
+
+
Assigned Groups
+
    + {% for group in object.groups.all %} +
  • {{ group }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+
+
+ {% if perms.extras.view_objectchange %} +
+
+
+
Recent Activity
+
+ {% render_table changelog_table 'inc/table.html' %} +
+
+
+
+ {% endif %} +{% endblock %} diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 35353b4a3f3..e89f358305e 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet): ) class Meta: - model = NetBoxUser + model = get_user_model() fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] def search(self, queryset, name, value): diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index ea586965429..20f779bf22a 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -146,34 +146,52 @@ def __init__(self, *args, **kwargs): class UserForm(BootstrapMixin, forms.ModelForm): groups = DynamicModelMultipleChoiceField( + required=False, queryset=Group.objects.all() ) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all() + ) fieldsets = ( ('User', ('username', 'first_name', 'last_name', 'email', )), ('Groups', ('groups', )), ('Status', ('is_active', 'is_staff', 'is_superuser', )), ('Important Dates', ('last_login', 'date_joined', )), + ('Permissions', ('object_permissions', )), ) class Meta: model = NetBoxUser fields = [ - 'username', 'first_name', 'last_name', 'email', 'groups', + 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions', 'is_active', 'is_staff', 'is_superuser', 'last_login', 'date_joined', ] class GroupForm(BootstrapMixin, forms.ModelForm): + users = DynamicModelMultipleChoiceField( + required=False, + queryset=get_user_model().objects.all() + ) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all() + ) fieldsets = ( - ('name', ), + ('', ('name', )), + ('Users', ('users', )), + ('Permissions', ('object_permissions', )), ) class Meta: model = NetBoxGroup fields = [ - 'name', + 'name', 'users', 'object_permissions', ] diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 4931d932743..cc181763f20 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -2,7 +2,7 @@ from django_tables2.utils import A from .models import Token from netbox.tables import NetBoxTable, columns -from users.models import NetBoxUser +from users.models import NetBoxGroup, NetBoxUser __all__ = ( 'GroupTable', @@ -73,21 +73,21 @@ class Meta(NetBoxTable.Meta): class GroupTable(NetBoxTable): - username = tables.LinkColumn('users:netboxuser', args=[A('pk')]) + name = tables.LinkColumn('users:netboxgroup', args=[A('pk')]) actions = columns.ActionsColumn( actions=('edit', 'delete'), ) class Meta(NetBoxTable.Meta): - model = NetBoxUser + model = NetBoxGroup fields = ( - 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + 'pk', 'id', 'name', 'users_count', ) - default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') + default_columns = ('pk', 'name', 'users_count', ) class ObjectPermissionTable(NetBoxTable): - username = tables.LinkColumn('users:netboxuser', args=[A('pk')]) + name = tables.LinkColumn('users:objectpermission', args=[A('pk')]) actions = columns.ActionsColumn( actions=('edit', 'delete'), ) @@ -95,6 +95,6 @@ class ObjectPermissionTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = NetBoxUser fields = ( - 'pk', 'id', 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + 'pk', 'id', 'name', 'enabled', 'actions', 'constraints', ) - default_columns = ('pk', 'username', 'email', 'first_name', 'last_name', 'is_superuser') + default_columns = ('pk', 'name', 'enabled', 'actions', 'constraints',) diff --git a/netbox/users/views.py b/netbox/users/views.py index 3bf6d5e1c7f..012b304537e 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,6 +6,7 @@ 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 +from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render, resolve_url from django.urls import reverse @@ -398,7 +399,7 @@ class NetBoxUserBulkDeleteView(generic.BulkDeleteView): class NetBoxGroupListView(generic.ObjectListView): - queryset = NetBoxGroup.objects.all() + queryset = NetBoxGroup.objects.all().annotate(users_count=Count('user')) filterset = filtersets.GroupFilterSet filterset_form = forms.GroupFilterForm table = tables.GroupTable @@ -409,6 +410,11 @@ class NetBoxGroupView(generic.ObjectView): queryset = NetBoxGroup.objects.all() template_name = 'users/group.html' + def get_extra_context(self, request, instance): + return { + 'active_tab': 'group', + } + @register_model_view(NetBoxGroup, 'edit') class NetBoxGroupEditView(generic.ObjectEditView): @@ -455,6 +461,11 @@ class ObjectPermissionView(generic.ObjectView): queryset = NetBoxGroup.objects.all() template_name = 'users/objectpermission.html' + def get_extra_context(self, request, instance): + return { + 'active_tab': 'objectpermission', + } + @register_model_view(ObjectPermission, 'edit') class ObjectPermissionEditView(generic.ObjectEditView): From f1eadc6a5c15d5e718d35b16037f94cab75692db Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Jun 2023 13:00:23 -0700 Subject: [PATCH 07/72] 125890 fix objectpermission form --- netbox/users/forms/model_forms.py | 84 +++++++++++++++++++++++++++++-- netbox/users/tables.py | 4 +- netbox/users/views.py | 4 +- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 20f779bf22a..dd30a081355 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe from django.utils.translation import gettext as _ @@ -11,9 +12,10 @@ from ipam.validators import prefix_validator from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin -from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import DateTimePicker from utilities.utils import flatten_dict +from users.constants import * from users.models import * @@ -183,7 +185,7 @@ class GroupForm(BootstrapMixin, forms.ModelForm): ) fieldsets = ( - ('', ('name', )), + (None, ('name', )), ('Users', ('users', )), ('Permissions', ('object_permissions', )), ) @@ -196,14 +198,86 @@ class Meta: class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): + users = DynamicModelMultipleChoiceField( + required=False, + queryset=get_user_model().objects.all() + ) + groups = DynamicModelMultipleChoiceField( + required=False, + queryset=Group.objects.all() + ) + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES + ) + + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) fieldsets = ( - ('name', 'description', 'enabled'), - ('User', ('username', 'first_name', 'last_name', 'email', )), + (None, ('name', 'description', 'enabled',)), + ('Actions', ('can_view', 'can_add', 'can_change', 'can_delete', 'actions')), + ('Objects', ('object_types')), + ('Assignment', ('groups', 'users')), + ('Constraints', ('constraints',)) ) class Meta: model = ObjectPermission fields = [ - 'name', 'description', 'enabled', + 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions', ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + super().clean() + + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if object_types and constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + tokens = { + CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID + } + model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index cc181763f20..ba81c282845 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -2,7 +2,7 @@ from django_tables2.utils import A from .models import Token from netbox.tables import NetBoxTable, columns -from users.models import NetBoxGroup, NetBoxUser +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission __all__ = ( 'GroupTable', @@ -93,7 +93,7 @@ class ObjectPermissionTable(NetBoxTable): ) class Meta(NetBoxTable.Meta): - model = NetBoxUser + model = ObjectPermission fields = ( 'pk', 'id', 'name', 'enabled', 'actions', 'constraints', ) diff --git a/netbox/users/views.py b/netbox/users/views.py index 012b304537e..16556dc2af7 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -450,7 +450,7 @@ class NetBoxGroupBulkDeleteView(generic.BulkDeleteView): class ObjectPermissionListView(generic.ObjectListView): - queryset = NetBoxGroup.objects.all() + queryset = ObjectPermission.objects.all() filterset = filtersets.ObjectPermissionFilterSet filterset_form = forms.ObjectPermissionFilterForm table = tables.ObjectPermissionTable @@ -458,7 +458,7 @@ class ObjectPermissionListView(generic.ObjectListView): @register_model_view(ObjectPermission) class ObjectPermissionView(generic.ObjectView): - queryset = NetBoxGroup.objects.all() + queryset = ObjectPermission.objects.all() template_name = 'users/objectpermission.html' def get_extra_context(self, request, instance): From 4560eb650856842ce5c9563bdf409278f7232f05 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Jun 2023 14:06:30 -0700 Subject: [PATCH 08/72] 125890 fixes --- netbox/templates/users/group.html | 6 +- netbox/templates/users/objectpermission.html | 6 +- netbox/users/forms/bulk_edit.py | 71 ++++++++------------ netbox/users/urls.py | 2 +- netbox/users/views.py | 10 +-- 5 files changed, 39 insertions(+), 56 deletions(-) diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html index ec5c0acad44..77e1a3f88bb 100644 --- a/netbox/templates/users/group.html +++ b/netbox/templates/users/group.html @@ -29,10 +29,10 @@
Account Details
-
Assigned Groups
+
Users
    - {% for group in request.user.groups.all %} -
  • {{ group }}
  • + {% for user in object.user_set.all %} +
  • {{ user }}
  • {% empty %}
  • None
  • {% endfor %} diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html index 4fdbb428a98..4f646515a08 100644 --- a/netbox/templates/users/objectpermission.html +++ b/netbox/templates/users/objectpermission.html @@ -29,10 +29,10 @@
    Account Details
-
Assigned Groups
+
Assigned Users
    - {% for group in request.user.groups.all %} -
  • {{ group }}
  • + {% for user in object.users.all %} +
  • {{ user }}
  • {% empty %}
  • None
  • {% endfor %} diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index c2b548e8d84..c444b4cf616 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -1,13 +1,11 @@ from django import forms from django.utils.translation import gettext as _ -from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices -from circuits.models import * -from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm -from tenancy.models import Tenant +from users.models import * from utilities.forms import add_blank_choice from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BootstrapMixin from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( @@ -17,44 +15,42 @@ ) -class UserBulkEditForm(NetBoxModelBulkEditForm): - asns = DynamicModelMultipleChoiceField( - queryset=ASN.objects.all(), - label=_('ASNs'), +class UserBulkEditForm(BootstrapMixin, forms.Form): + first_name = forms.CharField( + max_length=150, required=False ) - description = forms.CharField( - max_length=200, + last_name = forms.CharField( + max_length=150, required=False ) - comments = CommentField( - label=_('Comments') + is_active = forms.BooleanField( + required=False, + label=_('Active') + ) + is_staff = forms.BooleanField( + required=False, + label=_('Staff status') + ) + is_superuser = forms.BooleanField( + required=False, + label=_('Superuser status') ) - model = Provider + model = NetBoxUser fieldsets = ( - (None, ('asns', 'description')), - ) - nullable_fields = ( - 'asns', 'description', 'comments', + (None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')), ) + nullable_fields = () class GroupBulkEditForm(NetBoxModelBulkEditForm): - asns = DynamicModelMultipleChoiceField( - queryset=ASN.objects.all(), - label=_('ASNs'), - required=False - ) - description = forms.CharField( - max_length=200, + first_name = forms.CharField( + max_length=150, required=False ) - comments = CommentField( - label=_('Comments') - ) - model = Provider + model = NetBoxGroup fieldsets = ( (None, ('asns', 'description')), ) @@ -64,23 +60,10 @@ class GroupBulkEditForm(NetBoxModelBulkEditForm): class ObjectPermissionBulkEditForm(NetBoxModelBulkEditForm): - asns = DynamicModelMultipleChoiceField( - queryset=ASN.objects.all(), - label=_('ASNs'), - required=False - ) - description = forms.CharField( - max_length=200, - required=False - ) - comments = CommentField( - label=_('Comments') - ) - - model = Provider + model = ObjectPermission fieldsets = ( - (None, ('asns', 'description')), + (None, ('description')), ) nullable_fields = ( - 'asns', 'description', 'comments', + 'description', ) diff --git a/netbox/users/urls.py b/netbox/users/urls.py index caed08c45f3..fd248993acc 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -23,7 +23,7 @@ path('groups/', views.NetBoxGroupListView.as_view(), name='netboxgroup_list'), path('groups/add/', views.NetBoxGroupEditView.as_view(), name='netboxgroup_add'), path('groups/import/', views.NetBoxGroupBulkImportView.as_view(), name='netboxgroup_import'), - path('groups/edit/', views.NetBoxGroupBulkEditView.as_view(), name='netboxgroup_bulk_edit'), + # path('groups/edit/', views.NetBoxGroupBulkEditView.as_view(), name='netboxgroup_bulk_edit'), path('groups/delete/', views.NetBoxGroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), path('groups//', include(get_model_urls('users', 'netboxgroup'))), diff --git a/netbox/users/views.py b/netbox/users/views.py index 16556dc2af7..21f613e85df 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -432,11 +432,11 @@ class NetBoxGroupBulkImportView(generic.BulkImportView): model_form = forms.GroupImportForm -class NetBoxGroupBulkEditView(generic.BulkEditView): - queryset = NetBoxGroup.objects.all() - filterset = filtersets.GroupFilterSet - table = tables.GroupTable - form = forms.GroupBulkEditForm +# class NetBoxGroupBulkEditView(generic.BulkEditView): +# queryset = NetBoxGroup.objects.all() +# filterset = filtersets.GroupFilterSet +# table = tables.GroupTable +# form = forms.GroupBulkEditForm class NetBoxGroupBulkDeleteView(generic.BulkDeleteView): From 30a168a33e1f19c8fdf87818c65b3e898454ec89 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Jun 2023 14:40:41 -0700 Subject: [PATCH 09/72] 125890 fixes --- netbox/users/forms/model_forms.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index dd30a081355..39929afa170 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -229,11 +229,20 @@ class Meta: fields = [ 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions', ] + help_texts = { + 'actions': _('Actions granted in addition to those listed above'), + 'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.') + } + labels = { + 'actions': 'Additional actions' + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Make the actions field optional since the admin form uses it only for non-CRUD actions + # Make the actions field optional since the form uses it only for non-CRUD actions self.fields['actions'].required = False # Order group and user fields From 057fbf0ac4bd0e240a5ee4792f9b0a844612704a Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Jun 2023 09:36:46 -0700 Subject: [PATCH 10/72] 12589 fix boolean filters --- netbox/users/forms/bulk_edit.py | 18 ++++++++- netbox/users/forms/filtersets.py | 65 ++++++++++---------------------- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index c444b4cf616..9fe16db7bc1 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -16,6 +16,10 @@ class UserBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField( + queryset=None, # Set from self.model on init + widget=forms.MultipleHiddenInput + ) first_name = forms.CharField( max_length=150, required=False @@ -43,8 +47,16 @@ class UserBulkEditForm(BootstrapMixin, forms.Form): ) nullable_fields = () + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['pk'].queryset = self.model.objects.all() + -class GroupBulkEditForm(NetBoxModelBulkEditForm): +class GroupBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField( + queryset=None, # Set from self.model on init + widget=forms.MultipleHiddenInput + ) first_name = forms.CharField( max_length=150, required=False @@ -58,6 +70,10 @@ class GroupBulkEditForm(NetBoxModelBulkEditForm): 'asns', 'description', 'comments', ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['pk'].queryset = self.model.objects.all() + class ObjectPermissionBulkEditForm(NetBoxModelBulkEditForm): model = ObjectPermission diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index cef567230d3..0746b42a339 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -8,6 +8,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm from users.models import NetBoxUser +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.widgets import DatePicker, NumberWithOptions @@ -34,16 +35,25 @@ class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): last_name = forms.CharField( required=False ) - is_superuser = forms.BooleanField( + is_superuser = forms.NullBooleanField( required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), label='Is Superuser', ) - is_staff = forms.BooleanField( + is_staff = forms.NullBooleanField( required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), label='Is Staff', ) - is_active = forms.BooleanField( + is_active = forms.NullBooleanField( required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), label='Is Active', ) @@ -52,57 +62,22 @@ class GroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = NetBoxUser fieldsets = ( (None, ('q', 'filter_id',)), - ('Name', ('username', 'first_name', 'last_name')), - ('Security', ('is_superuser', 'is_staff', 'is_active')), - ) - username = forms.CharField( - required=False + ('Name', ('name',)), ) - first_name = forms.CharField( - required=False - ) - last_name = forms.CharField( + name = forms.CharField( required=False ) - is_superuser = forms.BooleanField( - required=False, - label='Is Superuser', - ) - is_staff = forms.BooleanField( - required=False, - label='Is Staff', - ) - is_active = forms.BooleanField( - required=False, - label='Is Active', - ) class ObjectPermissionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = NetBoxUser fieldsets = ( (None, ('q', 'filter_id',)), - ('Name', ('username', 'first_name', 'last_name')), - ('Security', ('is_superuser', 'is_staff', 'is_active')), - ) - username = forms.CharField( - required=False - ) - first_name = forms.CharField( - required=False - ) - last_name = forms.CharField( - required=False - ) - is_superuser = forms.BooleanField( - required=False, - label='Is Superuser', + ('Name', ('enabled',)), ) - is_staff = forms.BooleanField( + enabled = forms.NullBooleanField( required=False, - label='Is Staff', - ) - is_active = forms.BooleanField( - required=False, - label='Is Active', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) From 78ef1d14127651f2c32cf5e5ca68dd82b230f746 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Jun 2023 12:41:18 -0700 Subject: [PATCH 11/72] 12589 UI fixes --- netbox/templates/users/base.html | 7 ++++++- netbox/users/filtersets.py | 2 +- netbox/users/forms/model_forms.py | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index 58861ee90af..5b5f860f779 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -1,4 +1,9 @@ -{% extends 'base/layout.html' %} +{% extends 'generic/object.html' %} +{% load buttons %} +{% load static %} +{% load helpers %} +{% load plugins %} + {% block tabs %}
{% endblock %} diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 7df50787fc6..90fb5c74565 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -1,16 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice -from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices -from circuits.models import * -from dcim.models import Region, Site, SiteGroup -from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm -from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from users.models import ObjectPermission, NetBoxGroup, NetBoxUser -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice -from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField -from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( 'GroupFilterForm', @@ -19,7 +12,7 @@ ) -class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class UserFilterForm(NetBoxModelFilterSetForm): model = NetBoxUser fieldsets = ( (None, ('q', 'filter_id',)), @@ -48,14 +41,14 @@ class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): ) -class GroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class GroupFilterForm(NetBoxModelFilterSetForm): model = NetBoxGroup fieldsets = ( (None, ('q', 'filter_id',)), ) -class ObjectPermissionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): +class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): model = ObjectPermission fieldsets = ( (None, ('q', 'filter_id',)), From 101db0fc7bea8024847f9b6bf43b4d2d599fef26 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 10:58:19 +0700 Subject: [PATCH 47/72] 12589 review changes --- netbox/templates/users/user.html | 12 ++++++++++++ netbox/users/forms/bulk_edit.py | 4 ---- netbox/users/forms/bulk_import.py | 5 +---- .../users/migrations/0004_netboxgroup_netboxuser.py | 8 ++++++++ netbox/users/models.py | 8 +++++++- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 295f0f96281..64aab2d4ed0 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -57,7 +57,19 @@
{% trans "Assigned Groups" %}
{% endfor %}
+ +
+
{% trans "Assigned Permissions" %}
+
    + {% for perm in object.object_permissions.all %} +
  • {{ perm }}
  • + {% empty %} +
  • {% trans "None" %}
  • + {% endfor %} +
+
+ {% if perms.extras.view_objectchange %}
diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index 21e4d75e864..786d27c7d6c 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -1,12 +1,8 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from netbox.forms import NetBoxModelBulkEditForm from users.models import * -from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms import BootstrapMixin -from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( 'ObjectPermissionBulkEditForm', diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 618cb46fd10..ed08f03faa0 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -1,10 +1,7 @@ from django import forms -from users.models import * -from django.utils.translation import gettext_lazy as _ +from users.models import NetBoxGroup from netbox.forms import NetBoxModelImportForm -from utilities.forms import BootstrapMixin -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField __all__ = ( 'GroupImportForm', diff --git a/netbox/users/migrations/0004_netboxgroup_netboxuser.py b/netbox/users/migrations/0004_netboxgroup_netboxuser.py index 7bb746bd568..afbc0eefb10 100644 --- a/netbox/users/migrations/0004_netboxgroup_netboxuser.py +++ b/netbox/users/migrations/0004_netboxgroup_netboxuser.py @@ -39,4 +39,12 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.AlterModelOptions( + name='netboxgroup', + options={'ordering': ['name'], 'verbose_name': 'Group'}, + ), + migrations.AlterModelOptions( + name='netboxuser', + options={'ordering': ['username'], 'verbose_name': 'User'}, + ), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index ffafb8c9c6d..003eefb0aac 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -56,6 +56,10 @@ class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)): pass +class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)): + pass + + class NetBoxUser(User): """ Proxy contrib.auth.models.User for the UI @@ -65,6 +69,7 @@ class NetBoxUser(User): class Meta: verbose_name = 'User' proxy = True + ordering = ['username',] def get_absolute_url(self): return reverse('users:netboxuser', args=[self.pk]) @@ -74,11 +79,12 @@ class NetBoxGroup(Group): """ Proxy contrib.auth.models.User for the UI """ - objects = RestrictedQuerySet.as_manager() + objects = NetBoxGroupManager() class Meta: verbose_name = 'Group' proxy = True + ordering = ['name',] def get_absolute_url(self): return reverse('users:netboxgroup', args=[self.pk]) From 002c0bf4be1b2f44d23195822739855755ad195a Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 11:27:52 +0700 Subject: [PATCH 48/72] 12589 review changes --- netbox/users/views.py | 3 --- netbox/utilities/views.py | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/users/views.py b/netbox/users/views.py index 52ff091404c..1f997ca38d9 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -397,9 +397,6 @@ class NetBoxUserEditView(generic.ObjectEditView): class NetBoxUserDeleteView(generic.ObjectDeleteView): queryset = NetBoxUser.objects.all() - def get_required_permission(self): - return get_permission_for_model(User, 'delete') - class NetBoxUserBulkEditView(generic.BulkEditView): queryset = NetBoxUser.objects.all() diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c10e8bcbd34..a639524b7e0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -79,9 +79,8 @@ def has_permission(self): if user.has_perms((permission_required, *self.additional_permissions)): # Update the view's QuerySet to filter only the permitted objects - if isinstance(self.queryset, RestrictedQuerySet): - action = resolve_permission(permission_required)[1] - self.queryset = self.queryset.restrict(user, action) + action = resolve_permission(permission_required)[1] + self.queryset = self.queryset.restrict(user, action) return True From 6a98397626f21dd0bc2004ff28e0701fc90bbeec Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 11:54:12 +0700 Subject: [PATCH 49/72] 12589 review changes - permission proxy --- netbox/utilities/permissions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 5590adbc723..36b2295f205 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -18,11 +18,16 @@ def get_permission_for_model(model, action): :param model: A model or instance :param action: View, add, change, or delete (string) """ - ct = ContentType.objects.get_for_model(model) + + # Get non proxied model + concrete_model = model + while concrete_model._meta.proxy_for_model: + concrete_model = concrete_model._meta.proxy_for_model + return '{}.{}_{}'.format( - ct.app_label, + concrete_model._meta.app_label, action, - ct.model + concrete_model._meta.model_name ) From 8d84eec3e19f0d6af4dcabb87bf633c1b09bccd8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 12:43:47 +0700 Subject: [PATCH 50/72] 12589 review changes - change permission check --- netbox/utilities/permissions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 36b2295f205..6bdf2b9c49d 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -75,14 +75,17 @@ def permission_is_exempt(name): if action == 'view': if ( - # All models (excluding those in EXEMPT_EXCLUDE_MODELS) are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS and (f'{app_label}.{model_name}' in settings.EXEMPT_VIEW_PERMISSIONS or (app_label, model_name) not in settings.EXEMPT_EXCLUDE_MODELS) - ) or ( # This specific model is exempt from view permission enforcement f'{app_label}.{model_name}' in settings.EXEMPT_VIEW_PERMISSIONS ): return True + if ( + # All models (excluding those in EXEMPT_EXCLUDE_MODELS) are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS and ((app_label, model_name) not in settings.EXEMPT_EXCLUDE_MODELS) + ): + return True + return False From f9f3899144333adc18149ace142ce7c39ab3a50c Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 13:04:40 +0700 Subject: [PATCH 51/72] 12589 review changes - change permission check --- netbox/utilities/querysets.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index e5cbe38945c..c6d127e6dda 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -2,7 +2,7 @@ from django.db.models import Prefetch, QuerySet from users.constants import CONSTRAINT_TOKEN_USER -from utilities.permissions import permission_is_exempt, qs_filter_from_constraints +from utilities.permissions import permission_is_exempt, qs_filter_from_constraints, get_permission_for_model __all__ = ( 'RestrictedPrefetch', @@ -47,10 +47,7 @@ def restrict(self, user, action='view'): :param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view' """ # Resolve the full name of the required permission - ct = ContentType.objects.get_for_model(self.model) - app_label = ct.app_label - model_name = ct.model - permission_required = f'{app_label}.{action}_{model_name}' + permission_required = get_permission_for_model(model, action) # Bypass restriction for superusers and exempt views if user.is_superuser or permission_is_exempt(permission_required): From 32f772d1d752a2a77d3caa34caf18d8167e22be3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 13:29:50 +0700 Subject: [PATCH 52/72] 12589 review changes - change permission check --- netbox/utilities/querysets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index c6d127e6dda..aad16a780aa 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -47,7 +47,7 @@ def restrict(self, user, action='view'): :param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view' """ # Resolve the full name of the required permission - permission_required = get_permission_for_model(model, action) + permission_required = get_permission_for_model(self.model, action) # Bypass restriction for superusers and exempt views if user.is_superuser or permission_is_exempt(permission_required): From 1a33637e0849d831bb053ec33037efb3c34601c0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 14 Jul 2023 15:40:07 +0700 Subject: [PATCH 53/72] 12589 change password in edit view --- netbox/users/forms/model_forms.py | 25 ++++++++++------- netbox/users/urls.py | 1 - netbox/users/views.py | 46 ------------------------------- 3 files changed, 15 insertions(+), 57 deletions(-) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 15d16d052c7..8c20f53503c 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -199,25 +199,30 @@ def __init__(self, *args, **kwargs): # Adjust form fields depending if Add or Edit if self.instance.pk: self.fields['object_permissions'].initial = self.instance.object_permissions.all().values_list('id', flat=True) - self.fields['password'].disabled = True - self.fields['password'].required = False - self.fields['password'].help_text = _( - "Raw passwords are not stored, so there is no way to see this " - "user’s password, but you can change the password using " - 'this form.' - ).format(url=reverse('users:change_user_password', args=[self.instance.pk])) - print(self.fields['password'].help_text) - del self.fields['confirm_password'] + pw_field = self.fields['password'] + pwc_field = self.fields['confirm_password'] + pw_field.required = False + pw_field.widget.attrs.pop('required') + pw_field.help_text = _("Leave empty to keep the old password.") + pwc_field.required = False + pwc_field.widget.attrs.pop('required') def save(self, *args, **kwargs): + edited = getattr(self, 'instance', None) instance = super().save(*args, **kwargs) instance.object_permissions.set(self.cleaned_data['object_permissions']) + + # On edit, check if we have to save the password + if edited and self.cleaned_data.get("password"): + instance.set_password(self.cleaned_data.get("password")) + instance.save() + return instance def clean(self): cleaned_data = super().clean() instance = getattr(self, 'instance', None) - if not instance: + if not instance or cleaned_data.get("password"): password = cleaned_data.get("password") confirm_password = cleaned_data.get("confirm_password") diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 6e9f3ef701d..815a39ec89b 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -18,7 +18,6 @@ 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'))), - path('users/password//', views.NetBoxUserChangePasswordView.as_view(), name='change_user_password'), # Groups path('groups/', views.NetBoxGroupListView.as_view(), name='netboxgroup_list'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 1f997ca38d9..79a3d23e0b3 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -414,52 +414,6 @@ def get_required_permission(self): return get_permission_for_model(User, 'delete') -class NetBoxUserChangePasswordView(LoginRequiredMixin, View): - template_name = 'users/passworduser.html' - queryset = User.objects.all() - - def get_object(self, **kwargs): - """ - Return an object for editing. If no keyword arguments have been specified, this will be a new instance. - """ - if not kwargs: - # We're creating a new object - return self.queryset.model() - return get_object_or_404(self.queryset, **kwargs) - - def get(self, request, *args, **kwargs): - obj = self.get_object(**kwargs) - - # LDAP users cannot change their password here - if getattr(obj, 'ldap_username', None): - messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") - return redirect('users:netboxuser_list') - - form = forms.PasswordSetForm(user=obj) - - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'password', - 'object': obj, - }) - - def post(self, request, *args, **kwargs): - obj = self.get_object(**kwargs) - - form = forms.PasswordSetForm(user=obj, data=request.POST) - if form.is_valid(): - form.save() - update_session_auth_hash(request, form.user) - messages.success(request, "The password has been changed successfully.") - return redirect('users:netboxuser_list') - - return render(request, self.template_name, { - 'form': form, - 'active_tab': 'password', - 'object': obj, - }) - - # # Groups # From 68cd6efca6751c80564ef29169f69bf6ebf8d2ac Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 13:00:35 +0700 Subject: [PATCH 54/72] 12589 user bulk import --- netbox/users/forms/bulk_import.py | 28 +++++++++++++++++++++++++--- netbox/users/urls.py | 1 + netbox/users/views.py | 5 +++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index ed08f03faa0..723b121de1e 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -1,17 +1,39 @@ from django import forms -from users.models import NetBoxGroup -from netbox.forms import NetBoxModelImportForm +from users.models import NetBoxGroup, NetBoxUser +from utilities.forms import CSVModelForm __all__ = ( 'GroupImportForm', + 'UserImportForm', ) -class GroupImportForm(NetBoxModelImportForm): +class GroupImportForm(CSVModelForm): class Meta: model = NetBoxGroup fields = ( 'name', ) + + +class UserImportForm(CSVModelForm): + + class Meta: + model = NetBoxUser + fields = ( + 'username', 'first_name', 'last_name', 'email', 'password', 'is_staff', + 'is_active', 'is_superuser' + ) + + def save(self, *args, **kwargs): + edited = getattr(self, 'instance', None) + instance = super().save(*args, **kwargs) + + # On edit, check if we have to save the password + if edited and self.cleaned_data.get("password"): + instance.set_password(self.cleaned_data.get("password")) + instance.save() + + return instance diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 815a39ec89b..50965a16f4f 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -16,6 +16,7 @@ path('users/', views.NetBoxUserListView.as_view(), name='netboxuser_list'), path('users/add/', views.NetBoxUserEditView.as_view(), name='netboxuser_add'), path('users/edit/', views.NetBoxUserBulkEditView.as_view(), name='netboxuser_bulk_edit'), + path('users/import/', views.NetBoxUserBulkImportView.as_view(), name='netboxuser_import'), path('users/delete/', views.NetBoxUserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'), path('users//', include(get_model_urls('users', 'netboxuser'))), diff --git a/netbox/users/views.py b/netbox/users/views.py index 79a3d23e0b3..1a29b1dcd7c 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -405,6 +405,11 @@ class NetBoxUserBulkEditView(generic.BulkEditView): form = forms.UserBulkEditForm +class NetBoxUserBulkImportView(generic.BulkImportView): + queryset = NetBoxUser.objects.all() + model_form = forms.UserImportForm + + class NetBoxUserBulkDeleteView(generic.BulkDeleteView): queryset = NetBoxUser.objects.all() filterset = filtersets.UserFilterSet From cc3c64c97ee2cf4e98805809efa8d7cfb0501387 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 15:51:58 +0700 Subject: [PATCH 55/72] 12589 missing test file --- netbox/users/tests/test_views.py | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 netbox/users/tests/test_views.py diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py new file mode 100644 index 00000000000..6764581d784 --- /dev/null +++ b/netbox/users/tests/test_views.py @@ -0,0 +1,130 @@ +from decimal import Decimal + +import yaml +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 override_settings +from django.urls import reverse +from django.utils import timezone + +from dcim.choices import * +from dcim.constants import * +from users.models import * +from utilities.testing import ViewTestCases, TestCase + + +class UserTestCase(ViewTestCases.UserViewTestCase): + model = NetBoxUser + + @classmethod + def setUpTestData(cls): + + users = ( + NetBoxUser(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'), + NetBoxUser(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'), + ) + NetBoxUser.objects.bulk_create(users) + + cls.form_data = { + 'username': 'usernamex', + 'first_name': 'firstx', + 'last_name': 'lastx', + 'email': 'userx@foo.com', + 'password': 'pass1xxx', + 'confirm_password': 'pass1xxx', + } + + cls.csv_data = ( + "username,first_name,last_name,email,password", + "username4,first4,last4,email4@foo.com,pass4xxx", + "username5,first5,last5,email5@foo.com,pass5xxx", + "username6,first6,last6,email6@foo.com,pass6xxx", + ) + + cls.csv_update_data = ( + "id,first_name,last_name", + f"{users[0].pk},first7,last7", + f"{users[1].pk},first8,last8", + f"{users[2].pk},first9,last9", + ) + + cls.bulk_edit_data = { + 'last_name': 'newlastname', + } + + +class GroupTestCase(ViewTestCases.GroupViewTestCase): + model = NetBoxGroup + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='group1', ), + Group(name='group2', ), + Group(name='group3', ), + ) + Group.objects.bulk_create(groups) + + cls.form_data = { + 'name': 'groupx', + } + + cls.csv_data = ( + "name", + "group4" + "group5" + "group6" + ) + + cls.csv_update_data = ( + "id,name", + f"{groups[0].pk},group7", + f"{groups[1].pk},group8", + f"{groups[2].pk},group9", + ) + + +class ObjectPermissionTestCase(ViewTestCases.ObjectPermissionViewTestCase): + model = ObjectPermission + + @classmethod + def setUpTestData(cls): + + from dcim.models import Site + ct = ContentType.objects.get_for_model(Site) + + # Create three Regions + permissions = ( + ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']), + ObjectPermission(name='Permission 2', actions=['view', 'add', 'delete']), + ObjectPermission(name='Permission 3', actions=['view', 'add', 'delete']), + ) + ObjectPermission.objects.bulk_create(permissions) + + cls.form_data = { + 'name': 'Permission X', + 'description': 'A new permission', + 'object_types': [ct.pk,], + 'actions': 'view,edit,delete', + } + + cls.csv_data = ( + "name", + "permission4" + "permission5" + "permission6" + ) + + cls.csv_update_data = ( + "id,name,actions", + f"{permissions[0].pk},permission7", + f"{permissions[1].pk},permission8", + f"{permissions[2].pk},permission9", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } From 55d19189f0f2e928481db74bad19ec65504c1786 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 16:09:04 +0700 Subject: [PATCH 56/72] 12589 dont check password field for tests --- netbox/utilities/testing/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 4b4644762e8..109dcc93273 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -278,7 +278,7 @@ def test_edit_object_with_permission(self): 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data, exclude=['password',]) if hasattr(self.model, "to_objectchange"): # Verify ObjectChange creation From 17b045685c35f6dcf6a70c0c71512812749a36b7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 16:49:22 +0700 Subject: [PATCH 57/72] 12589 dont check password field for tests --- netbox/utilities/testing/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 109dcc93273..70ff543f82e 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -315,7 +315,7 @@ def test_edit_object_with_constrained_permission(self): 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data, exclude=['password',]) # Try to edit a non-permitted object request = { From a1af7f2f76723341abd953020e921e300404a509 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 17:59:24 +0700 Subject: [PATCH 58/72] 12589 remove special perm --- netbox/users/views.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/users/views.py b/netbox/users/views.py index 1a29b1dcd7c..c272cf923ba 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -415,9 +415,6 @@ class NetBoxUserBulkDeleteView(generic.BulkDeleteView): filterset = filtersets.UserFilterSet table = tables.UserTable - def get_required_permission(self): - return get_permission_for_model(User, 'delete') - # # Groups From 2c7c3bc2dac373d2338c43f5897e3933026d70f6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 17 Jul 2023 20:27:19 +0700 Subject: [PATCH 59/72] 12589 update menu permissions for auth models --- netbox/netbox/navigation/__init__.py | 20 ++++++++++++++------ netbox/netbox/navigation/menu.py | 4 ++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index a05b1c495cc..61e0466db82 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -60,17 +60,25 @@ def name(self): # Utility functions # -def get_model_item(app_label, model_name, label, actions=('add', 'import')): +def get_model_item(app_label, model_name, label, actions=('add', 'import'), permission_app_label=None, permission_model_name=None): + if not permission_app_label: + permission_app_label = app_label + if not permission_model_name: + permission_model_name = model_name return MenuItem( link=f'{app_label}:{model_name}_list', link_text=label, - permissions=[f'{app_label}.view_{model_name}'], - buttons=get_model_buttons(app_label, model_name, actions) + permissions=[f'{permission_app_label}.view_{permission_model_name}'], + buttons=get_model_buttons(app_label, model_name, actions, permission_app_label, permission_model_name) ) -def get_model_buttons(app_label, model_name, actions=('add', 'import')): +def get_model_buttons(app_label, model_name, actions=('add', 'import'), permission_app_label=None, permission_model_name=None): buttons = [] + if not permission_app_label: + permission_app_label = app_label + if not permission_model_name: + permission_model_name = model_name if 'add' in actions: buttons.append( @@ -78,7 +86,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')): link=f'{app_label}:{model_name}_add', title='Add', icon_class='mdi mdi-plus-thick', - permissions=[f'{app_label}.add_{model_name}'], + permissions=[f'{permission_app_label}.add_{permission_model_name}'], color=ButtonColorChoices.GREEN ) ) @@ -88,7 +96,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')): link=f'{app_label}:{model_name}_import', title='Import', icon_class='mdi mdi-upload', - permissions=[f'{app_label}.add_{model_name}'], + permissions=[f'{permission_app_label}.add_{permission_model_name}'], color=ButtonColorChoices.CYAN ) ) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 1597512793f..d94160d34c6 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -353,8 +353,8 @@ MenuGroup( label=_('Users'), items=( - get_model_item('users', 'netboxuser', _('Users'), actions=['add']), - get_model_item('users', 'netboxgroup', _('Groups'), actions=['add']), + get_model_item('users', 'netboxuser', _('Users'), actions=['add'], permission_app_label='auth', permission_model_name='users'), + get_model_item('users', 'netboxgroup', _('Groups'), actions=['add'], permission_app_label='auth', permission_model_name='groups'), get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), ), ), From dd70ba9470590f4a9c451157cb24fbcbb5d7eaba Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 20 Jul 2023 15:19:07 +0700 Subject: [PATCH 60/72] 12589 fix friggin test case --- netbox/users/tests/test_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 6764581d784..7fd9818f4b3 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -17,6 +17,11 @@ class UserTestCase(ViewTestCases.UserViewTestCase): model = NetBoxUser + def setUp(self): + get_user_model().objects.create_user(username='dummyuser1') + get_user_model().objects.create_user(username='dummyuser2') + super().setUp() + @classmethod def setUpTestData(cls): From 6a5c44f237a5b63c0797d61014dfc45c02c0c640 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Jul 2023 09:18:38 -0400 Subject: [PATCH 61/72] Reorganize account view templates --- .../users/{ => account}/api_token.html | 0 .../users/{ => account}/api_tokens.html | 2 +- .../{base_profile.html => account/base.html} | 0 .../users/{ => account}/bookmarks.html | 2 +- .../users/{ => account}/password.html | 2 +- .../users/{ => account}/preferences.html | 2 +- .../users/{ => account}/profile.html | 2 +- netbox/templates/users/base.html | 11 -------- netbox/templates/users/group.html | 2 +- netbox/templates/users/objectpermission.html | 2 +- netbox/templates/users/passworduser.html | 28 ------------------- netbox/templates/users/user.html | 2 +- netbox/users/urls.py | 10 +++---- netbox/users/views.py | 12 ++++---- 14 files changed, 18 insertions(+), 59 deletions(-) rename netbox/templates/users/{ => account}/api_token.html (100%) rename netbox/templates/users/{ => account}/api_tokens.html (94%) rename netbox/templates/users/{base_profile.html => account/base.html} (100%) rename netbox/templates/users/{ => account}/bookmarks.html (95%) rename netbox/templates/users/{ => account}/password.html (94%) rename netbox/templates/users/{ => account}/preferences.html (98%) rename netbox/templates/users/{ => account}/profile.html (98%) delete mode 100644 netbox/templates/users/base.html delete mode 100644 netbox/templates/users/passworduser.html diff --git a/netbox/templates/users/api_token.html b/netbox/templates/users/account/api_token.html similarity index 100% rename from netbox/templates/users/api_token.html rename to netbox/templates/users/account/api_token.html diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/account/api_tokens.html similarity index 94% rename from netbox/templates/users/api_tokens.html rename to netbox/templates/users/account/api_tokens.html index aaff36a4411..25f5f02e6d6 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/account/api_tokens.html @@ -1,4 +1,4 @@ -{% extends 'users/base_profile.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/base_profile.html b/netbox/templates/users/account/base.html similarity index 100% rename from netbox/templates/users/base_profile.html rename to netbox/templates/users/account/base.html diff --git a/netbox/templates/users/bookmarks.html b/netbox/templates/users/account/bookmarks.html similarity index 95% rename from netbox/templates/users/bookmarks.html rename to netbox/templates/users/account/bookmarks.html index 4695f509e96..fa3c28c7c30 100644 --- a/netbox/templates/users/bookmarks.html +++ b/netbox/templates/users/account/bookmarks.html @@ -1,4 +1,4 @@ -{% extends 'users/base_profile.html' %} +{% extends 'users/account/base.html' %} {% load buttons %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/password.html b/netbox/templates/users/account/password.html similarity index 94% rename from netbox/templates/users/password.html rename to netbox/templates/users/account/password.html index 77c152de847..dcdd19e295b 100644 --- a/netbox/templates/users/password.html +++ b/netbox/templates/users/account/password.html @@ -1,4 +1,4 @@ -{% extends 'users/base_profile.html' %} +{% extends 'users/account/base.html' %} {% load form_helpers %} {% block title %}Change Password{% endblock %} diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/account/preferences.html similarity index 98% rename from netbox/templates/users/preferences.html rename to netbox/templates/users/account/preferences.html index 3d67e7b24f4..59cca302c2b 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/account/preferences.html @@ -1,4 +1,4 @@ -{% extends 'users/base_profile.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load form_helpers %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/account/profile.html similarity index 98% rename from netbox/templates/users/profile.html rename to netbox/templates/users/account/profile.html index 0922f9ddf65..0e8ab1162df 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/account/profile.html @@ -1,4 +1,4 @@ -{% extends 'users/base_profile.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html deleted file mode 100644 index 9711dc79e0b..00000000000 --- a/netbox/templates/users/base.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load static %} -{% load helpers %} -{% load plugins %} - -{% block content-wrapper %} -
- {% block content %}{% endblock %} -
-{% endblock %} diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html index bb699468b2d..dd4b1aa8ac8 100644 --- a/netbox/templates/users/group.html +++ b/netbox/templates/users/group.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'generic/object.html' %} {% load i18n %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html index 2ef6acbae08..26a5d06d3d5 100644 --- a/netbox/templates/users/objectpermission.html +++ b/netbox/templates/users/objectpermission.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'generic/object.html' %} {% load i18n %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/passworduser.html b/netbox/templates/users/passworduser.html deleted file mode 100644 index 3b50c3bc0cd..00000000000 --- a/netbox/templates/users/passworduser.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'users/base_profile.html' %} -{% load i18n %} -{% load form_helpers %} - -{% block title %}{% trans "Change Password" %}{% endblock %} - -{% block tabs %} - -{% endblock tabs %} - -{% block content %} -
- {% csrf_token %} -
-
{% trans "Password" %}
- {% render_field form.new_password1 %} - {% render_field form.new_password2 %} -
-
- {% trans "Cancel" %} - -
-
-{% endblock %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 64aab2d4ed0..549144336f6 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'generic/object.html' %} {% load i18n %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 50965a16f4f..573a44224db 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -6,11 +6,14 @@ app_name = 'users' urlpatterns = [ - # User + # Account views path('profile/', views.ProfileView.as_view(), name='profile'), path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('password/', views.ChangePasswordView.as_view(), name='change_password'), + path('api-tokens/', views.TokenListView.as_view(), name='token_list'), + path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), + path('api-tokens//', include(get_model_urls('users', 'token'))), # Users path('users/', views.NetBoxUserListView.as_view(), name='netboxuser_list'), @@ -34,9 +37,4 @@ path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'), path('permissions//', include(get_model_urls('users', 'objectpermission'))), - # API tokens - path('api-tokens/', views.TokenListView.as_view(), name='token_list'), - path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), - path('api-tokens//', include(get_model_urls('users', 'token'))), - ] diff --git a/netbox/users/views.py b/netbox/users/views.py index c272cf923ba..c93b8cc82cb 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -158,7 +158,7 @@ def get(self, request): # class ProfileView(LoginRequiredMixin, View): - template_name = 'users/profile.html' + template_name = 'users/account/profile.html' def get(self, request): @@ -177,7 +177,7 @@ def get(self, request): class UserConfigView(LoginRequiredMixin, View): - template_name = 'users/preferences.html' + template_name = 'users/account/preferences.html' def get(self, request): userconfig = request.user.config @@ -205,7 +205,7 @@ def post(self, request): class ChangePasswordView(LoginRequiredMixin, View): - template_name = 'users/password.html' + template_name = 'users/account/password.html' def get(self, request): # LDAP users cannot change their password here @@ -240,7 +240,7 @@ def post(self, request): class BookmarkListView(LoginRequiredMixin, generic.ObjectListView): table = BookmarkTable - template_name = 'users/bookmarks.html' + template_name = 'users/account/bookmarks.html' def get_queryset(self, request): return Bookmark.objects.filter(user=request.user) @@ -263,7 +263,7 @@ def get(self, request): table = tables.TokenTable(tokens) table.configure(request) - return render(request, 'users/api_tokens.html', { + return render(request, 'users/account/api_tokens.html', { 'tokens': tokens, 'active_tab': 'api-tokens', 'table': table, @@ -307,7 +307,7 @@ def post(self, request, pk=None): messages.success(request, msg) if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: - return render(request, 'users/api_token.html', { + return render(request, 'users/account/api_token.html', { 'object': token, 'key': token.key, 'return_url': reverse('users:token_list'), From d8ad97e4b82b6a0a9596a84d88d48a7724790c59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Jul 2023 09:40:04 -0400 Subject: [PATCH 62/72] Create menu items manually for users & groups to accomodate proxy models --- netbox/netbox/navigation/__init__.py | 20 +++++------------ netbox/netbox/navigation/menu.py | 33 ++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index 61e0466db82..a05b1c495cc 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -60,25 +60,17 @@ def name(self): # Utility functions # -def get_model_item(app_label, model_name, label, actions=('add', 'import'), permission_app_label=None, permission_model_name=None): - if not permission_app_label: - permission_app_label = app_label - if not permission_model_name: - permission_model_name = model_name +def get_model_item(app_label, model_name, label, actions=('add', 'import')): return MenuItem( link=f'{app_label}:{model_name}_list', link_text=label, - permissions=[f'{permission_app_label}.view_{permission_model_name}'], - buttons=get_model_buttons(app_label, model_name, actions, permission_app_label, permission_model_name) + permissions=[f'{app_label}.view_{model_name}'], + buttons=get_model_buttons(app_label, model_name, actions) ) -def get_model_buttons(app_label, model_name, actions=('add', 'import'), permission_app_label=None, permission_model_name=None): +def get_model_buttons(app_label, model_name, actions=('add', 'import')): buttons = [] - if not permission_app_label: - permission_app_label = app_label - if not permission_model_name: - permission_model_name = model_name if 'add' in actions: buttons.append( @@ -86,7 +78,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import'), permissi link=f'{app_label}:{model_name}_add', title='Add', icon_class='mdi mdi-plus-thick', - permissions=[f'{permission_app_label}.add_{permission_model_name}'], + permissions=[f'{app_label}.add_{model_name}'], color=ButtonColorChoices.GREEN ) ) @@ -96,7 +88,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import'), permissi link=f'{app_label}:{model_name}_import', title='Import', icon_class='mdi mdi-upload', - permissions=[f'{permission_app_label}.add_{permission_model_name}'], + permissions=[f'{app_label}.add_{model_name}'], color=ButtonColorChoices.CYAN ) ) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 47f58bc8b5d..4b77e6816de 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,6 +1,7 @@ from django.utils.translation import gettext as _ from netbox.registry import registry +from utilities.choices import ButtonColorChoices from . import * # @@ -354,8 +355,36 @@ MenuGroup( label=_('Users'), items=( - get_model_item('users', 'netboxuser', _('Users'), actions=['add'], permission_app_label='auth', permission_model_name='users'), - get_model_item('users', 'netboxgroup', _('Groups'), actions=['add'], permission_app_label='auth', permission_model_name='groups'), + # Proxy model for auth.User + MenuItem( + link=f'users:netboxuser_list', + link_text=_('Users'), + permissions=[f'auth.view_user'], + buttons=( + MenuItemButton( + link=f'users:netboxuser_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'auth.add_user'], + color=ButtonColorChoices.GREEN + ), + ) + ), + # Proxy model for auth.Group + MenuItem( + link=f'users:netboxgroup_list', + link_text=_('Groups'), + permissions=[f'auth.view_group'], + buttons=( + MenuItemButton( + link=f'users:netboxgroup_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=[f'auth.add_group'], + color=ButtonColorChoices.GREEN + ), + ) + ), get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), ), ), From 937961baa1bcc4fe1e95c9a51c5c91d8ac292e07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Jul 2023 09:50:44 -0400 Subject: [PATCH 63/72] Restore bookmarks tab on account views --- netbox/templates/users/account/base.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/netbox/templates/users/account/base.html b/netbox/templates/users/account/base.html index 3275b859cbc..f492f89ecf4 100644 --- a/netbox/templates/users/account/base.html +++ b/netbox/templates/users/account/base.html @@ -1,15 +1,14 @@ {% extends 'base/layout.html' %} {% load i18n %} -{% load buttons %} -{% load static %} -{% load helpers %} -{% load plugins %} {% block tabs %}
diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index 549144336f6..fe03f41ed3f 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -5,11 +5,13 @@ {% block title %}{% trans "User" %} {{ object.username }}{% endblock %} +{% block subtitle %}{% endblock %} + {% block content %}
-
{% trans "Account Details" %}
+
{% trans "User" %}
@@ -18,13 +20,7 @@
{% trans "Account Details" %}
- + @@ -35,13 +31,17 @@
{% trans "Account Details" %}
- - + + - + + + + +
{% trans "Full Name" %} - {% if object.first_name or object.last_name %} - {{ object.first_name }} {{ object.last_name }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ object.get_full_name|placeholder }}
{% trans "Email" %} {{ object.date_joined|annotated_date }}
{% trans "Superuser" %}{% checkmark object.is_superuser %}{% trans "Active" %}{% checkmark object.active %}
{% trans "Admin Access" %}{% trans "Staff" %} {% checkmark object.is_staff %}
{% trans "Superuser" %}{% checkmark object.is_superuser %}
@@ -49,27 +49,25 @@
{% trans "Account Details" %}
{% trans "Assigned Groups" %}
-
    +
    {% for group in object.groups.all %} -
  • {{ group }}
  • + {{ group }} {% empty %} -
  • {% trans "None" %}
  • +
    {% trans "None" %}
    {% endfor %} -
+
-
{% trans "Assigned Permissions" %}
-
    +
    {% for perm in object.object_permissions.all %} -
  • {{ perm }}
  • + {{ perm }} {% empty %} -
  • {% trans "None" %}
  • +
    {% trans "None" %}
    {% endfor %} -
+
- {% if perms.extras.view_objectchange %}
From 235da43d29e00094391944a69d425122b2a581ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Jul 2023 16:04:57 -0400 Subject: [PATCH 72/72] Clean up tests --- netbox/users/tests/test_views.py | 70 +++++++++++-------- netbox/utilities/testing/views.py | 110 +++++++++--------------------- 2 files changed, 77 insertions(+), 103 deletions(-) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 7fd9818f4b3..ca62f474e53 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -1,26 +1,27 @@ -from decimal import Decimal - -import yaml -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 override_settings -from django.urls import reverse -from django.utils import timezone -from dcim.choices import * -from dcim.constants import * from users.models import * -from utilities.testing import ViewTestCases, TestCase - - -class UserTestCase(ViewTestCases.UserViewTestCase): +from utilities.testing import ViewTestCases + + +class UserTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): model = NetBoxUser + maxDiff = None + validation_excluded_fields = ['password'] - def setUp(self): - get_user_model().objects.create_user(username='dummyuser1') - get_user_model().objects.create_user(username='dummyuser2') - super().setUp() + def _get_queryset(self): + # Omit the user attached to the test client + return self.model.objects.exclude(username='testuser') @classmethod def setUpTestData(cls): @@ -60,16 +61,25 @@ def setUpTestData(cls): } -class GroupTestCase(ViewTestCases.GroupViewTestCase): +class GroupTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): model = NetBoxGroup + maxDiff = None @classmethod def setUpTestData(cls): groups = ( - Group(name='group1', ), - Group(name='group2', ), - Group(name='group3', ), + Group(name='group1'), + Group(name='group2'), + Group(name='group3'), ) Group.objects.bulk_create(groups) @@ -92,16 +102,22 @@ def setUpTestData(cls): ) -class ObjectPermissionTestCase(ViewTestCases.ObjectPermissionViewTestCase): +class ObjectPermissionTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): model = ObjectPermission + maxDiff = None @classmethod def setUpTestData(cls): + ct = ContentType.objects.get_by_natural_key('dcim', 'site') - from dcim.models import Site - ct = ContentType.objects.get_for_model(Site) - - # Create three Regions permissions = ( ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']), ObjectPermission(name='Permission 2', actions=['view', 'add', 'delete']), @@ -112,7 +128,7 @@ def setUpTestData(cls): cls.form_data = { 'name': 'Permission X', 'description': 'A new permission', - 'object_types': [ct.pk,], + 'object_types': [ct.pk], 'actions': 'view,edit,delete', } diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 70ff543f82e..539fe30571f 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -125,10 +125,9 @@ class GetObjectChangelogViewTestCase(ModelViewTestCase): """ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_get_object_changelog(self): - if hasattr(self.model, "to_objectchange"): - url = self._get_url('changelog', self._get_queryset().first()) - response = self.client.get(url) - self.assertHttpStatus(response, 200) + url = self._get_url('changelog', self._get_queryset().first()) + response = self.client.get(url) + self.assertHttpStatus(response, 200) class CreateObjectViewTestCase(ModelViewTestCase): """ @@ -137,6 +136,7 @@ class CreateObjectViewTestCase(ModelViewTestCase): :form_data: Data to be used when creating a new object. """ form_data = {} + validation_excluded_fields = [] def test_create_object_without_permission(self): @@ -155,7 +155,6 @@ def test_create_object_without_permission(self): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): - initial_count = self._get_queryset().count() # Assign unconstrained permission obj_perm = ObjectPermission( @@ -170,21 +169,18 @@ def test_create_object_with_permission(self): self.assertHttpStatus(self.client.get(self._get_url('add')), 200) # Try POST with model-level permission + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - - if self.model == ObjectPermission: - # if this test is for ObjectPermission we just created another one - initial_count += 1 self.assertEqual(initial_count + 1, self._get_queryset().count()) instance = self._get_queryset().order_by('pk').last() - self.assertInstanceEqual(instance, self.form_data, exclude=['password']) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) - if hasattr(self.model, "to_objectchange"): - # Verify ObjectChange creation + # Verify ObjectChange creation + if issubclass(instance.__class__, ChangeLoggingMixin): objectchanges = ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk @@ -194,7 +190,6 @@ def test_create_object_with_permission(self): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): - initial_count = self._get_queryset().count() # Assign constrained permission obj_perm = ObjectPermission( @@ -210,14 +205,12 @@ def test_create_object_with_constrained_permission(self): self.assertHttpStatus(self.client.get(self._get_url('add')), 200) # Try to create an object (not permitted) + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 200) - if self.model == ObjectPermission: - # if this test is for ObjectPermission we just created another one - initial_count += 1 self.assertEqual(initial_count, self._get_queryset().count()) # Check that no object was created # Update the ObjectPermission to allow creation @@ -231,7 +224,8 @@ def test_create_object_with_constrained_permission(self): } self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) - self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data, exclude=['password']) + instance = self._get_queryset().order_by('pk').last() + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) class EditObjectViewTestCase(ModelViewTestCase): """ @@ -240,6 +234,7 @@ class EditObjectViewTestCase(ModelViewTestCase): :form_data: Data to be used when updating the first existing object. """ form_data = {} + validation_excluded_fields = [] def test_edit_object_without_permission(self): instance = self._get_queryset().first() @@ -278,10 +273,11 @@ def test_edit_object_with_permission(self): 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data, exclude=['password',]) + instance = self._get_queryset().get(pk=instance.pk) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) - if hasattr(self.model, "to_objectchange"): - # Verify ObjectChange creation + # Verify ObjectChange creation + if issubclass(instance.__class__, ChangeLoggingMixin): objectchanges = ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk @@ -315,7 +311,8 @@ def test_edit_object_with_constrained_permission(self): 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data, exclude=['password',]) + instance = self._get_queryset().get(pk=instance1.pk) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Try to edit a non-permitted object request = { @@ -475,10 +472,19 @@ def test_list_objects_with_constrained_permission(self): self.assertIn(instance1.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user', 'auth.group', 'users.objectpermission']) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_export_objects(self): url = self._get_url('list') + # Add model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + # Test default CSV export response = self.client.get(f'{url}?export') self.assertHttpStatus(response, 200) @@ -657,7 +663,7 @@ def test_bulk_update_objects_with_permission(self): if value is not None and not isinstance(field, ForeignKey): self.assertEqual(value, value) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*',]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): initial_count = self._get_queryset().count() data = { @@ -711,7 +717,7 @@ def test_bulk_edit_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user', 'auth.group', 'users.objectpermission']) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_permission(self): pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) data = { @@ -725,7 +731,7 @@ def test_bulk_edit_objects_with_permission(self): # Assign model-level permission obj_perm = ObjectPermission( name='Test permission', - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -736,7 +742,7 @@ def test_bulk_edit_objects_with_permission(self): for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user', 'auth.group', 'users.objectpermission']) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_constrained_permission(self): pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) data = { @@ -756,7 +762,7 @@ def test_bulk_edit_objects_with_constrained_permission(self): obj_perm = ObjectPermission( name='Test permission', constraints={attr_name: value}, - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -820,7 +826,6 @@ def test_bulk_delete_objects_with_permission(self): @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_constrained_permission(self): - initial_count = self._get_queryset().count() pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, @@ -839,10 +844,8 @@ def test_bulk_delete_objects_with_constrained_permission(self): obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Attempt to bulk delete non-permitted objects + initial_count = self._get_queryset().count() self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - if self.model == ObjectPermission: - # if this test is for ObjectPermission we just created another one - initial_count += 1 self.assertEqual(self._get_queryset().count(), initial_count) # Update permission constraints @@ -997,48 +1000,3 @@ class DeviceComponentViewTestCase( TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.) """ maxDiff = None - - class UserViewTestCase( - GetObjectViewTestCase, - GetObjectChangelogViewTestCase, - CreateObjectViewTestCase, - EditObjectViewTestCase, - DeleteObjectViewTestCase, - ListObjectsViewTestCase, - BulkEditObjectsViewTestCase, - BulkDeleteObjectsViewTestCase, - ): - """ - TestCase suitable for testing all standard View functions for auth.user objects - """ - maxDiff = None - - class GroupViewTestCase( - GetObjectViewTestCase, - GetObjectChangelogViewTestCase, - CreateObjectViewTestCase, - EditObjectViewTestCase, - DeleteObjectViewTestCase, - ListObjectsViewTestCase, - BulkImportObjectsViewTestCase, - BulkDeleteObjectsViewTestCase, - ): - """ - TestCase suitable for testing all standard View functions for auth.group objects - """ - maxDiff = None - - class ObjectPermissionViewTestCase( - GetObjectViewTestCase, - GetObjectChangelogViewTestCase, - CreateObjectViewTestCase, - EditObjectViewTestCase, - DeleteObjectViewTestCase, - ListObjectsViewTestCase, - BulkEditObjectsViewTestCase, - BulkDeleteObjectsViewTestCase, - ): - """ - TestCase suitable for testing all standard View functions for auth.group objects - """ - maxDiff = None