From a4acb50edd3746a35255f2dd49775660119e0d8c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 21 Jul 2023 03:22:08 +0700 Subject: [PATCH] 12589 move user and group admin from admin (#12877) Move admin views for users, groups, and object permissions from the admin site to the NetBox frontend --------- Co-authored-by: Jeremy Stretch --- netbox/netbox/navigation/menu.py | 51 +++ netbox/netbox/views/generic/mixins.py | 1 + .../users/{ => account}/api_token.html | 0 .../users/{ => account}/api_tokens.html | 2 +- .../templates/users/{ => account}/base.html | 11 +- .../users/{ => account}/bookmarks.html | 2 +- .../users/{ => account}/password.html | 2 +- .../users/{ => account}/preferences.html | 2 +- .../users/{ => account}/profile.html | 2 +- netbox/templates/users/group.html | 48 +++ netbox/templates/users/objectpermission.html | 97 +++++ netbox/templates/users/user.html | 84 ++++ netbox/users/admin/__init__.py | 98 ----- netbox/users/admin/forms.py | 117 +----- netbox/users/filtersets.py | 21 +- netbox/users/forms.py | 130 ------ netbox/users/forms/__init__.py | 5 + netbox/users/forms/authentication.py | 25 ++ netbox/users/forms/bulk_edit.py | 72 ++++ netbox/users/forms/bulk_import.py | 32 ++ netbox/users/forms/filtersets.py | 111 +++++ netbox/users/forms/model_forms.py | 381 ++++++++++++++++++ .../migrations/0004_netboxgroup_netboxuser.py | 50 +++ netbox/users/models.py | 63 ++- netbox/users/tables.py | 77 +++- netbox/users/tests/test_filtersets.py | 30 +- netbox/users/tests/test_views.py | 151 +++++++ netbox/users/urls.py | 26 +- netbox/users/views.py | 177 +++++++- netbox/utilities/permissions.py | 9 +- netbox/utilities/querysets.py | 6 +- netbox/utilities/testing/views.py | 83 ++-- 32 files changed, 1545 insertions(+), 421 deletions(-) rename netbox/templates/users/{ => account}/api_token.html (100%) rename netbox/templates/users/{ => account}/api_tokens.html (94%) rename netbox/templates/users/{ => account}/base.html (68%) 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%) create mode 100644 netbox/templates/users/group.html create mode 100644 netbox/templates/users/objectpermission.html create mode 100644 netbox/templates/users/user.html delete mode 100644 netbox/users/forms.py create mode 100644 netbox/users/forms/__init__.py create mode 100644 netbox/users/forms/authentication.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 create mode 100644 netbox/users/migrations/0004_netboxgroup_netboxuser.py create mode 100644 netbox/users/tests/test_views.py diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 1f6853884..45de28f2b 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 * # @@ -351,6 +352,56 @@ ADMIN_MENU = Menu( label=_('Admin'), icon_class='mdi mdi-account-multiple', groups=( + MenuGroup( + label=_('Users'), + items=( + # 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 + ), + MenuItemButton( + link=f'users:netboxuser_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'auth.add_user'], + color=ButtonColorChoices.CYAN + ) + ) + ), + # 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 + ), + MenuItemButton( + link=f'users:netboxgroup_import', + title='Import', + icon_class='mdi mdi-upload', + permissions=[f'auth.add_group'], + color=ButtonColorChoices.CYAN + ) + ) + ), + get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), + ), + ), MenuGroup( label=_('Configuration'), items=( diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index 8e363f0a5..a55f01509 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -22,6 +22,7 @@ class ActionsMixin: 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/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 e1641468c..25f5f02e6 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/account/api_tokens.html @@ -1,4 +1,4 @@ -{% extends 'users/base.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/account/base.html similarity index 68% rename from netbox/templates/users/base.html rename to netbox/templates/users/account/base.html index e07e28ced..f492f89ec 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/account/base.html @@ -1,23 +1,24 @@ {% extends 'base/layout.html' %} +{% load i18n %} {% block tabs %} {% endblock %} 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 66f367a1c..fa3c28c7c 100644 --- a/netbox/templates/users/bookmarks.html +++ b/netbox/templates/users/account/bookmarks.html @@ -1,4 +1,4 @@ -{% extends 'users/base.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 02e80bb26..dcdd19e29 100644 --- a/netbox/templates/users/password.html +++ b/netbox/templates/users/account/password.html @@ -1,4 +1,4 @@ -{% extends 'users/base.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 f2c88db3c..59cca302c 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/account/preferences.html @@ -1,4 +1,4 @@ -{% extends 'users/base.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 913784c94..0e8ab1162 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/account/profile.html @@ -1,4 +1,4 @@ -{% extends 'users/base.html' %} +{% extends 'users/account/base.html' %} {% load helpers %} {% load render_table from django_tables2 %} diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html new file mode 100644 index 000000000..e4eee0812 --- /dev/null +++ b/netbox/templates/users/group.html @@ -0,0 +1,48 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Group" %}
+
+ + + + + +
{% trans "Name" %}{{ object.name }}
+
+
+
+
+
+
{% trans "Users" %}
+
+ {% for user in object.user_set.all %} + {{ user }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Permissions" %}
+
+ {% for perm in object.object_permissions.all %} + {{ perm }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html new file mode 100644 index 000000000..4da5a6ea5 --- /dev/null +++ b/netbox/templates/users/objectpermission.html @@ -0,0 +1,97 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Permission" %}
+
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Enabled" %}{% checkmark object.enabled %}
+
+
+
+
{% trans "Actions" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "View" %}{% checkmark object.can_view %}
{% trans "Add" %}{% checkmark object.can_add %}
{% trans "Change" %}{% checkmark object.can_change %}
{% trans "Delete" %}{% checkmark object.can_delete %}
+
+
+
+
{% trans "Constraints" %}
+
+ {% if object.constraints %} +
{{ object.constraints|json }}
+ {% else %} + None + {% endif %} +
+
+
+
+
+
{% trans "Object Types" %}
+
    + {% for user in object.object_types.all %} +
  • {{ user }}
  • + {% endfor %} +
+
+
+
{% trans "Assigned Users" %}
+
+ {% for user in object.users.all %} + {{ user }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html new file mode 100644 index 000000000..fe03f41ed --- /dev/null +++ b/netbox/templates/users/user.html @@ -0,0 +1,84 @@ +{% extends 'generic/object.html' %} +{% load i18n %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}{% trans "User" %} {{ object.username }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "User" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Username" %}{{ object.username }}
{% trans "Full Name" %}{{ object.get_full_name|placeholder }}
{% trans "Email" %}{{ object.email|placeholder }}
{% trans "Account Created" %}{{ object.date_joined|annotated_date }}
{% trans "Active" %}{% checkmark object.active %}
{% trans "Staff" %}{% checkmark object.is_staff %}
{% trans "Superuser" %}{% checkmark object.is_superuser %}
+
+
+
+
+
+
{% trans "Assigned Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
{% trans "Assigned Permissions" %}
+
+ {% for perm in object.object_permissions.all %} + {{ perm }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+ {% if perms.extras.view_objectchange %} +
+
+
+
{% trans "Recent Activity" %}
+
+ {% render_table changelog_table 'inc/table.html' %} +
+
+
+
+ {% endif %} +{% endblock %} diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 2db822cfe..316346c50 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -15,41 +15,6 @@ admin.site.unregister(Group) admin.site.unregister(User) -@admin.register(Group) -class GroupAdmin(admin.ModelAdmin): - form = forms.GroupAdminForm - list_display = ('name', 'user_count') - ordering = ('name',) - search_fields = ('name',) - inlines = [inlines.GroupObjectPermissionInline] - - @staticmethod - def user_count(obj): - return obj.user_set.count() - - -@admin.register(User) -class UserAdmin(UserAdmin_): - list_display = [ - 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' - ] - fieldsets = ( - (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), - ('Groups', {'fields': ('groups',)}), - ('Status', { - 'fields': ('is_active', 'is_staff', 'is_superuser'), - }), - ('Important dates', {'fields': ('last_login', 'date_joined')}), - ) - filter_horizontal = ('groups',) - list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name') - - def get_inlines(self, request, obj): - if obj is not None: - return (inlines.UserObjectPermissionInline, inlines.UserConfigInline) - return () - - # # REST API tokens # @@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin): def list_allowed_ips(self, obj): return obj.allowed_ips or 'Any' list_allowed_ips.short_description = "Allowed IPs" - - -# -# Permissions -# - -@admin.register(ObjectPermission) -class ObjectPermissionAdmin(admin.ModelAdmin): - actions = ('enable', 'disable') - fieldsets = ( - (None, { - 'fields': ('name', 'description', 'enabled') - }), - ('Actions', { - 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') - }), - ('Objects', { - 'fields': ('object_types',) - }), - ('Assignment', { - 'fields': ('groups', 'users') - }), - ('Constraints', { - 'fields': ('constraints',), - 'classes': ('monospace',) - }), - ) - filter_horizontal = ('object_types', 'groups', 'users') - form = forms.ObjectPermissionForm - list_display = [ - 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', - ] - list_filter = [ - 'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users' - ] - search_fields = ['actions', 'constraints', 'description', 'name'] - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') - - def list_models(self, obj): - return ', '.join([f"{ct}" for ct in obj.object_types.all()]) - list_models.short_description = 'Models' - - def list_users(self, obj): - return ', '.join([u.username for u in obj.users.all()]) - list_users.short_description = 'Users' - - def list_groups(self, obj): - return ', '.join([g.name for g in obj.groups.all()]) - list_groups.short_description = 'Groups' - - # - # Admin actions - # - - def enable(self, request, queryset): - updated = queryset.update(enabled=True) - self.message_user(request, f"Enabled {updated} permissions") - - def disable(self, request, queryset): - updated = queryset.update(enabled=False) - self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 986ddd0aa..7db6a124c 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -1,49 +1,13 @@ from django import forms -from django.contrib.auth.models import Group, User -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, ValidationError from django.utils.translation import gettext as _ -from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES -from users.models import ObjectPermission, Token -from utilities.forms.fields import ContentTypeMultipleChoiceField -from utilities.permissions import qs_filter_from_constraints +from users.models import Token __all__ = ( - 'GroupAdminForm', - 'ObjectPermissionForm', 'TokenAdminForm', ) -class GroupAdminForm(forms.ModelForm): - users = forms.ModelMultipleChoiceField( - queryset=User.objects.all(), - required=False, - widget=FilteredSelectMultiple('users', False) - ) - - class Meta: - model = Group - fields = ('name', 'users') - - def __init__(self, *args, **kwargs): - super(GroupAdminForm, self).__init__(*args, **kwargs) - - if self.instance.pk: - self.fields['users'].initial = self.instance.user_set.all() - - def save_m2m(self): - self.instance.user_set.set(self.cleaned_data['users']) - - def save(self, *args, **kwargs): - instance = super(GroupAdminForm, self).save() - self.save_m2m() - - return instance - - class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, @@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm): 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token - - -class ObjectPermissionForm(forms.ModelForm): - 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) - - class Meta: - model = ObjectPermission - exclude = [] - 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' - } - widgets = { - 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) - } - - 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/filtersets.py b/netbox/users/filtersets.py index 44ad98cc2..a4e9a9fbc 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet): class Meta: model = get_user_model() - fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] + fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser'] def search(self, queryset, name, value): if not value.strip(): @@ -115,6 +115,18 @@ class ObjectPermissionFilterSet(BaseFilterSet): method='search', label=_('Search'), ) + can_view = django_filters.BooleanFilter( + method='_check_action' + ) + can_add = django_filters.BooleanFilter( + method='_check_action' + ) + can_change = django_filters.BooleanFilter( + method='_check_action' + ) + can_delete = django_filters.BooleanFilter( + method='_check_action' + ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', queryset=get_user_model().objects.all(), @@ -149,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet): Q(name__icontains=value) | Q(description__icontains=value) ) + + def _check_action(self, queryset, name, value): + action = name.split('_')[1] + if value: + return queryset.filter(actions__contains=[action]) + else: + return queryset.exclude(actions__contains=[action]) diff --git a/netbox/users/forms.py b/netbox/users/forms.py deleted file mode 100644 index 027fa5327..000000000 --- a/netbox/users/forms.py +++ /dev/null @@ -1,130 +0,0 @@ -from django import forms -from django.conf import settings -from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm -from django.contrib.postgres.forms import SimpleArrayField -from django.utils.html import mark_safe -from django.utils.translation import gettext as _ - -from ipam.formfields import IPNetworkFormField -from ipam.validators import prefix_validator -from netbox.preferences import PREFERENCES -from utilities.forms import BootstrapMixin -from utilities.forms.widgets import DateTimePicker -from utilities.utils import flatten_dict -from .models import Token, UserConfig - - -class LoginForm(BootstrapMixin, AuthenticationForm): - pass - - -class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): - pass - - -class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): - - def __new__(mcs, name, bases, attrs): - - # Emulate a declared field for each supported user preference - preference_fields = {} - for field_name, preference in PREFERENCES.items(): - description = f'{preference.description}
' if preference.description else '' - help_text = f'{description}{field_name}' - field_kwargs = { - 'label': preference.label, - 'choices': preference.choices, - 'help_text': mark_safe(help_text), - 'coerce': preference.coerce, - 'required': False, - 'widget': forms.Select, - } - preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) - attrs.update(preference_fields) - - return super().__new__(mcs, name, bases, attrs) - - -class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): - fieldsets = ( - ('User Interface', ( - 'pagination.per_page', - 'pagination.placement', - 'ui.colormode', - )), - ('Miscellaneous', ( - 'data_format', - )), - ) - # List of clearable preferences - pk = forms.MultipleChoiceField( - choices=[], - required=False - ) - - class Meta: - model = UserConfig - fields = () - - def __init__(self, *args, instance=None, **kwargs): - - # Get initial data from UserConfig instance - initial_data = flatten_dict(instance.data) - kwargs['initial'] = initial_data - - super().__init__(*args, instance=instance, **kwargs) - - # Compile clearable preference choices - self.fields['pk'].choices = ( - (f'tables.{table_name}', '') for table_name in instance.data.get('tables', []) - ) - - def save(self, *args, **kwargs): - - # Set UserConfig data - for pref_name, value in self.cleaned_data.items(): - if pref_name == 'pk': - continue - self.instance.set(pref_name, value, commit=False) - - # Clear selected preferences - for preference in self.cleaned_data['pk']: - self.instance.clear(preference) - - return super().save(*args, **kwargs) - - @property - def plugin_fields(self): - return [ - name for name in self.fields.keys() if name.startswith('plugins.') - ] - - -class TokenForm(BootstrapMixin, forms.ModelForm): - key = forms.CharField( - required=False, - help_text=_("If no key is provided, one will be generated automatically.") - ) - allowed_ips = SimpleArrayField( - base_field=IPNetworkFormField(validators=[prefix_validator]), - required=False, - label=_('Allowed IPs'), - help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64'), - ) - - class Meta: - model = Token - fields = [ - 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', - ] - widgets = { - 'expires': DateTimePicker(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Omit the key field if token retrieval is not permitted - if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL: - del self.fields['key'] diff --git a/netbox/users/forms/__init__.py b/netbox/users/forms/__init__.py new file mode 100644 index 000000000..a545c3add --- /dev/null +++ b/netbox/users/forms/__init__.py @@ -0,0 +1,5 @@ +from .authentication import * +from .bulk_edit import * +from .bulk_import import * +from .filtersets import * +from .model_forms import * diff --git a/netbox/users/forms/authentication.py b/netbox/users/forms/authentication.py new file mode 100644 index 000000000..2b540b752 --- /dev/null +++ b/netbox/users/forms/authentication.py @@ -0,0 +1,25 @@ +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordChangeForm as DjangoPasswordChangeForm, +) + +from utilities.forms import BootstrapMixin + +__all__ = ( + 'LoginForm', + 'PasswordChangeForm', +) + + +class LoginForm(BootstrapMixin, AuthenticationForm): + """ + Used to authenticate a user by username and password. + """ + pass + + +class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): + """ + This form enables a user to change his or her own password. + """ + pass diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py new file mode 100644 index 000000000..db40283ba --- /dev/null +++ b/netbox/users/forms/bulk_edit.py @@ -0,0 +1,72 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from users.models import * +from utilities.forms import BootstrapMixin +from utilities.forms.widgets import BulkEditNullBooleanSelect + +__all__ = ( + 'ObjectPermissionBulkEditForm', + 'UserBulkEditForm', +) + + +class UserBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField( + queryset=NetBoxUser.objects.all(), + widget=forms.MultipleHiddenInput + ) + first_name = forms.CharField( + label=_('First name'), + max_length=150, + required=False + ) + last_name = forms.CharField( + label=_('Last name'), + max_length=150, + required=False + ) + is_active = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Active') + ) + is_staff = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Staff status') + ) + is_superuser = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Superuser status') + ) + + model = NetBoxUser + fieldsets = ( + (None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')), + ) + nullable_fields = ('first_name', 'last_name') + + +class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField( + queryset=ObjectPermission.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label=_('Enabled') + ) + + model = ObjectPermission + fieldsets = ( + (None, ('enabled', 'description')), + ) + nullable_fields = ('description',) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py new file mode 100644 index 000000000..25f779044 --- /dev/null +++ b/netbox/users/forms/bulk_import.py @@ -0,0 +1,32 @@ +from users.models import NetBoxGroup, NetBoxUser +from utilities.forms import CSVModelForm + +__all__ = ( + 'GroupImportForm', + 'UserImportForm', +) + + +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): + # Set the hashed password + self.instance.set_password(self.cleaned_data.get('password')) + + return super().save(*args, **kwargs) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py new file mode 100644 index 000000000..eca76dea4 --- /dev/null +++ b/netbox/users/forms/filtersets.py @@ -0,0 +1,111 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.utils.translation import gettext_lazy as _ + +from netbox.forms import NetBoxModelFilterSetForm +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES +from utilities.forms.fields import DynamicModelMultipleChoiceField + +__all__ = ( + 'GroupFilterForm', + 'ObjectPermissionFilterForm', + 'UserFilterForm', +) + + +class GroupFilterForm(NetBoxModelFilterSetForm): + model = NetBoxGroup + fieldsets = ( + (None, ('q', 'filter_id',)), + ) + + +class UserFilterForm(NetBoxModelFilterSetForm): + model = NetBoxUser + fieldsets = ( + (None, ('q', 'filter_id',)), + (_('Group'), ('group_id',)), + (_('Status'), ('is_active', 'is_staff', 'is_superuser')), + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) + is_active = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Active'), + ) + is_staff = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Staff'), + ) + is_superuser = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Is Superuser'), + ) + + +class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): + model = ObjectPermission + fieldsets = ( + (None, ('q', 'filter_id',)), + (_('Permission'), ('enabled', 'group_id', 'user_id')), + (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete')), + ) + enabled = forms.NullBooleanField( + label=_('Enabled'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) + user_id = DynamicModelMultipleChoiceField( + queryset=get_user_model().objects.all(), + required=False, + label=_('User') + ) + can_view = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can View'), + ) + can_add = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Add'), + ) + can_change = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Change'), + ) + can_delete = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Can Delete'), + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py new file mode 100644 index 000000000..43b95893a --- /dev/null +++ b/netbox/users/forms/model_forms.py @@ -0,0 +1,381 @@ +from django import forms +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.forms import SimpleArrayField +from django.core.exceptions import FieldError +from django.utils.html import mark_safe +from django.utils.translation import gettext_lazy as _ + +from ipam.formfields import IPNetworkFormField +from ipam.validators import prefix_validator +from netbox.preferences import PREFERENCES +from users.constants import * +from users.models import * +from utilities.forms import BootstrapMixin +from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.widgets import DateTimePicker +from utilities.permissions import qs_filter_from_constraints +from utilities.utils import flatten_dict + +__all__ = ( + 'GroupForm', + 'ObjectPermissionForm', + 'TokenForm', + 'UserConfigForm', + 'UserForm', +) + + +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( + label=_('Pk'), + 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( + label=_('Key'), + 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): + password = forms.CharField( + label=_('Password'), + widget=forms.PasswordInput(), + required=True, + ) + confirm_password = forms.CharField( + label=_('Confirm password'), + widget=forms.PasswordInput(), + required=True, + help_text=_("Enter the same password as before, for verification."), + ) + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), + required=False, + queryset=Group.objects.all() + ) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all(), + to_field_name='pk', + ) + + fieldsets = ( + (_('User'), ('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email')), + (_('Groups'), ('groups', )), + (_('Status'), ('is_active', 'is_staff', 'is_superuser')), + (_('Permissions'), ('object_permissions',)), + ) + + class Meta: + model = NetBoxUser + fields = [ + 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions', + 'is_active', 'is_staff', 'is_superuser', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + # Populate assigned permissions + self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True) + + # Password fields are optional for existing Users + self.fields['password'].required = False + self.fields['password'].widget.attrs.pop('required') + self.fields['confirm_password'].required = False + self.fields['confirm_password'].widget.attrs.pop('required') + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Update assigned permissions + instance.object_permissions.set(self.cleaned_data['object_permissions']) + + # On edit, check if we have to save the password + if self.cleaned_data.get('password'): + instance.set_password(self.cleaned_data.get('password')) + instance.save() + + return instance + + def clean(self): + + # Check that password confirmation matches if password is set + if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']: + raise forms.ValidationError(_("Passwords do not match! Please check your input and try again.")) + + # TODO: Move this logic to the NetBoxUser class + def clean_username(self): + """Reject usernames that differ only in case.""" + instance = getattr(self, 'instance', None) + if instance: + qs = self._meta.model.objects.exclude(pk=instance.pk) + else: + qs = self._meta.model.objects.all() + + username = self.cleaned_data.get("username") + if ( + username and qs.filter(username__iexact=username).exists() + ): + raise forms.ValidationError( + _("user with this username already exists") + ) + + return username + + +class GroupForm(BootstrapMixin, forms.ModelForm): + users = DynamicModelMultipleChoiceField( + label=_('Users'), + required=False, + queryset=get_user_model().objects.all() + ) + object_permissions = DynamicModelMultipleChoiceField( + required=False, + label=_('Permissions'), + queryset=ObjectPermission.objects.all(), + to_field_name='pk', + ) + + fieldsets = ( + (None, ('name', )), + (_('Users'), ('users', )), + (_('Permissions'), ('object_permissions', )), + ) + + class Meta: + model = NetBoxGroup + fields = [ + 'name', 'users', 'object_permissions', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Populate assigned users and permissions + if self.instance.pk: + self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True) + self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Update assigned users and permissions + instance.user_set.set(self.cleaned_data['users']) + instance.object_permissions.set(self.cleaned_data['object_permissions']) + + return instance + + +class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): + object_types = ContentTypeMultipleChoiceField( + label=_('Object types'), + queryset=ContentType.objects.all(), + limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, + widget=forms.SelectMultiple(attrs={'size': 6}) + ) + can_view = forms.BooleanField( + required=False + ) + can_add = forms.BooleanField( + required=False + ) + can_change = forms.BooleanField( + required=False + ) + can_delete = forms.BooleanField( + required=False + ) + actions = SimpleArrayField( + label=_('Additional actions'), + base_field=forms.CharField(), + required=False, + help_text=_('Actions granted in addition to those listed above') + ) + users = DynamicModelMultipleChoiceField( + label=_('Users'), + required=False, + queryset=get_user_model().objects.all() + ) + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), + required=False, + queryset=Group.objects.all() + ) + + fieldsets = ( + (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', 'object_types', 'users', 'groups', 'constraints', 'actions', + ] + help_texts = { + '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.' + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # 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 + 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 forms.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 forms.ValidationError({ + 'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e) + }) diff --git a/netbox/users/migrations/0004_netboxgroup_netboxuser.py b/netbox/users/migrations/0004_netboxgroup_netboxuser.py new file mode 100644 index 000000000..59d941643 --- /dev/null +++ b/netbox/users/migrations/0004_netboxgroup_netboxuser.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.9 on 2023-06-06 18:15 + +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='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=[], + options={ + 'verbose_name': 'User', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('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 4e7d9ca52..a8060dd63 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -2,13 +2,14 @@ import binascii import os from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, GroupManager, User, UserManager from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from netaddr import IPNetwork @@ -20,6 +21,8 @@ from utilities.utils import flatten_dict from .constants import * __all__ = ( + 'NetBoxGroup', + 'NetBoxUser', 'ObjectPermission', 'Token', 'UserConfig', @@ -30,6 +33,7 @@ __all__ = ( # Proxy models for admin # + class AdminGroup(Group): """ Proxy contrib.auth.models.Group for the admin UI @@ -48,6 +52,44 @@ class AdminUser(User): proxy = True +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 + """ + objects = NetBoxUserManager() + + class Meta: + verbose_name = 'User' + proxy = True + ordering = ('username',) + + def get_absolute_url(self): + return reverse('users:netboxuser', args=[self.pk]) + + +class NetBoxGroup(Group): + """ + Proxy contrib.auth.models.User for the UI + """ + objects = NetBoxGroupManager() + + class Meta: + verbose_name = 'Group' + proxy = True + ordering = ('name',) + + def get_absolute_url(self): + return reverse('users:netboxgroup', args=[self.pk]) + + # # User preferences # @@ -325,6 +367,22 @@ class ObjectPermission(models.Model): def __str__(self): return self.name + @property + def can_view(self): + return 'view' in self.actions + + @property + def can_add(self): + return 'add' in self.actions + + @property + def can_change(self): + return 'change' in self.actions + + @property + def can_delete(self): + return 'delete' in self.actions + def list_constraints(self): """ Return all constraint sets as a list (even if only a single set is defined). @@ -332,3 +390,6 @@ class ObjectPermission(models.Model): if type(self.constraints) is not list: return [self.constraints] return self.constraints + + def get_absolute_url(self): + return reverse('users:objectpermission', args=[self.pk]) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index cea50b10f..741a4b024 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,8 +1,14 @@ -from .models import Token +import django_tables2 as tables + from netbox.tables import NetBoxTable, columns +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission +from .models import Token __all__ = ( + 'GroupTable', + 'ObjectPermissionTable', 'TokenTable', + 'UserTable', ) @@ -48,3 +54,72 @@ class TokenTable(NetBoxTable): fields = ( 'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', ) + + +class UserTable(NetBoxTable): + username = tables.Column( + linkify=True + ) + groups = columns.ManyToManyColumn( + linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + ) + is_active = columns.BooleanColumn() + is_staff = columns.BooleanColumn() + is_superuser = columns.BooleanColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxUser + fields = ( + 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', + 'is_superuser', + ) + default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') + + +class GroupTable(NetBoxTable): + name = tables.Column(linkify=True) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = NetBoxGroup + fields = ( + 'pk', 'id', 'name', 'users_count', + ) + default_columns = ('pk', 'name', 'users_count', ) + + +class ObjectPermissionTable(NetBoxTable): + name = tables.Column(linkify=True) + object_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + can_view = columns.BooleanColumn() + can_add = columns.BooleanColumn() + can_change = columns.BooleanColumn() + can_delete = columns.BooleanColumn() + custom_actions = columns.ArrayColumn( + accessor=tables.A('actions') + ) + users = columns.ManyToManyColumn( + linkify_item=('users:netboxuser', {'pk': tables.A('pk')}) + ) + groups = columns.ManyToManyColumn( + linkify_item=('users:netboxgroup', {'pk': tables.A('pk')}) + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = ObjectPermission + fields = ( + 'pk', 'id', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', + 'custom_actions', 'users', 'groups', 'constraints', 'description', + ) + default_columns = ( + 'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description', + ) diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index d632687ef..542b40b83 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -10,7 +10,6 @@ from users import filtersets from users.models import ObjectPermission, Token from utilities.testing import BaseFilterSetTests - User = get_user_model() @@ -34,7 +33,8 @@ class UserTestCase(TestCase, BaseFilterSetTests): first_name='Hank', last_name='Hill', email='hank@stricklandpropane.com', - is_staff=True + is_staff=True, + is_superuser=True ), User( username='User2', @@ -83,13 +83,17 @@ class UserTestCase(TestCase, BaseFilterSetTests): params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_is_active(self): + params = {'is_active': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_is_staff(self): params = {'is_staff': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_is_active(self): - params = {'is_active': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_is_superuser(self): + params = {'is_superuser': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_group(self): groups = Group.objects.all()[:2] @@ -191,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_can_view(self): + params = {'can_view': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_add(self): + params = {'can_add': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_change(self): + params = {'can_change': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_can_delete(self): + params = {'can_delete': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class TokenTestCase(TestCase, BaseFilterSetTests): queryset = Token.objects.all() diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py new file mode 100644 index 000000000..ca62f474e --- /dev/null +++ b/netbox/users/tests/test_views.py @@ -0,0 +1,151 @@ +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType + +from users.models import * +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 _get_queryset(self): + # Omit the user attached to the test client + return self.model.objects.exclude(username='testuser') + + @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.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.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.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') + + 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', + } diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 7cb1f3435..ca331d144 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -6,15 +6,35 @@ from . import views 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'), - - # 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'))), + # Users + path('users/', views.UserListView.as_view(), name='netboxuser_list'), + path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'), + path('users/edit/', views.UserBulkEditView.as_view(), name='netboxuser_bulk_edit'), + path('users/import/', views.UserBulkImportView.as_view(), name='netboxuser_import'), + path('users/delete/', views.UserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'), + path('users//', include(get_model_urls('users', 'netboxuser'))), + + # Groups + path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'), + path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'), + path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'), + path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'), + path('groups//', include(get_model_urls('users', 'netboxgroup'))), + + # Permissions + path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'), + path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'), + 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'))), + ] diff --git a/netbox/users/views.py b/netbox/users/views.py index ad80fdfe5..99635b514 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,6 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda 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 @@ -19,12 +20,11 @@ from extras.models import Bookmark, ObjectChange from extras.tables import BookmarkTable, ObjectChangeTable from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.config import get_config -from netbox.views.generic import ObjectListView +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, NetBoxGroup, NetBoxUser, ObjectPermission # @@ -70,7 +70,7 @@ class LoginView(View): return auth_backends def get(self, request): - form = LoginForm(request) + form = forms.LoginForm(request) if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') @@ -83,7 +83,7 @@ class LoginView(View): def post(self, request): logger = logging.getLogger('netbox.auth.login') - form = LoginForm(request, data=request.POST) + form = forms.LoginForm(request, data=request.POST) if form.is_valid(): logger.debug("Login form validation was successful") @@ -155,7 +155,7 @@ class LogoutView(View): # class ProfileView(LoginRequiredMixin, View): - template_name = 'users/profile.html' + template_name = 'users/account/profile.html' def get(self, request): @@ -174,11 +174,11 @@ class ProfileView(LoginRequiredMixin, View): class UserConfigView(LoginRequiredMixin, View): - template_name = 'users/preferences.html' + template_name = 'users/account/preferences.html' 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, @@ -187,7 +187,7 @@ class UserConfigView(LoginRequiredMixin, View): def post(self, request): userconfig = request.user.config - form = UserConfigForm(request.POST, instance=userconfig) + form = forms.UserConfigForm(request.POST, instance=userconfig) if form.is_valid(): form.save() @@ -202,7 +202,7 @@ class UserConfigView(LoginRequiredMixin, View): 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 @@ -210,7 +210,7 @@ class ChangePasswordView(LoginRequiredMixin, View): messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") return redirect('users:profile') - form = PasswordChangeForm(user=request.user) + form = forms.PasswordChangeForm(user=request.user) return render(request, self.template_name, { 'form': form, @@ -218,7 +218,7 @@ class ChangePasswordView(LoginRequiredMixin, View): }) def post(self, request): - form = PasswordChangeForm(user=request.user, data=request.POST) + form = forms.PasswordChangeForm(user=request.user, data=request.POST) if form.is_valid(): form.save() update_session_auth_hash(request, form.user) @@ -235,9 +235,9 @@ class ChangePasswordView(LoginRequiredMixin, View): # Bookmarks # -class BookmarkListView(LoginRequiredMixin, ObjectListView): +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) @@ -257,10 +257,10 @@ 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', { + return render(request, 'users/account/api_tokens.html', { 'tokens': tokens, 'active_tab': 'api-tokens', 'table': table, @@ -277,7 +277,7 @@ class TokenEditView(LoginRequiredMixin, View): else: token = Token(user=request.user) - form = TokenForm(instance=token) + form = forms.TokenForm(instance=token) return render(request, 'generic/object_edit.html', { 'object': token, @@ -289,10 +289,10 @@ class TokenEditView(LoginRequiredMixin, View): if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) - form = TokenForm(request.POST, instance=token) + form = forms.TokenForm(request.POST, instance=token) else: token = Token(user=request.user) - form = TokenForm(request.POST) + form = forms.TokenForm(request.POST) if form.is_valid(): @@ -304,7 +304,7 @@ class TokenEditView(LoginRequiredMixin, View): 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'), @@ -353,3 +353,138 @@ class TokenDeleteView(LoginRequiredMixin, View): 'form': form, 'return_url': reverse('users:token_list'), }) + +# +# Users +# + + +class UserListView(generic.ObjectListView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + filterset_form = forms.UserFilterForm + table = tables.UserTable + + +@register_model_view(NetBoxUser) +class UserView(generic.ObjectView): + queryset = NetBoxUser.objects.all() + template_name = 'users/user.html' + + def get_extra_context(self, request, instance): + changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20] + changelog_table = ObjectChangeTable(changelog) + + return { + 'changelog_table': changelog_table, + } + + +@register_model_view(NetBoxUser, 'edit') +class UserEditView(generic.ObjectEditView): + queryset = NetBoxUser.objects.all() + form = forms.UserForm + + +@register_model_view(NetBoxUser, 'delete') +class UserDeleteView(generic.ObjectDeleteView): + queryset = NetBoxUser.objects.all() + + +class UserBulkEditView(generic.BulkEditView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + table = tables.UserTable + form = forms.UserBulkEditForm + + +class UserBulkImportView(generic.BulkImportView): + queryset = NetBoxUser.objects.all() + model_form = forms.UserImportForm + + +class UserBulkDeleteView(generic.BulkDeleteView): + queryset = NetBoxUser.objects.all() + filterset = filtersets.UserFilterSet + table = tables.UserTable + + +# +# Groups +# + + +class GroupListView(generic.ObjectListView): + queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + filterset = filtersets.GroupFilterSet + filterset_form = forms.GroupFilterForm + table = tables.GroupTable + + +@register_model_view(NetBoxGroup) +class GroupView(generic.ObjectView): + queryset = NetBoxGroup.objects.all() + template_name = 'users/group.html' + + +@register_model_view(NetBoxGroup, 'edit') +class GroupEditView(generic.ObjectEditView): + queryset = NetBoxGroup.objects.all() + form = forms.GroupForm + + +@register_model_view(NetBoxGroup, 'delete') +class GroupDeleteView(generic.ObjectDeleteView): + queryset = NetBoxGroup.objects.all() + + +class GroupBulkImportView(generic.BulkImportView): + queryset = NetBoxGroup.objects.all() + model_form = forms.GroupImportForm + + +class GroupBulkDeleteView(generic.BulkDeleteView): + queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) + filterset = filtersets.GroupFilterSet + table = tables.GroupTable + +# +# ObjectPermissions +# + + +class ObjectPermissionListView(generic.ObjectListView): + queryset = ObjectPermission.objects.all() + filterset = filtersets.ObjectPermissionFilterSet + filterset_form = forms.ObjectPermissionFilterForm + table = tables.ObjectPermissionTable + + +@register_model_view(ObjectPermission) +class ObjectPermissionView(generic.ObjectView): + queryset = ObjectPermission.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 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 diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b20aafce0..813a8f944 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -18,11 +18,10 @@ def get_permission_for_model(model, action): :param model: A model or instance :param action: View, add, change, or delete (string) """ - return '{}.{}_{}'.format( - model._meta.app_label, - action, - model._meta.model_name - ) + # Resolve to the "concrete" model (for proxy models) + model = model._meta.concrete_model + + return f'{model._meta.app_label}.{action}_{model._meta.model_name}' def resolve_permission(name): diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index ba4b28418..50917dd0f 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,7 +1,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 get_permission_for_model, permission_is_exempt, qs_filter_from_constraints __all__ = ( 'RestrictedPrefetch', @@ -46,9 +46,7 @@ class RestrictedQuerySet(QuerySet): :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 - app_label = self.model._meta.app_label - model_name = self.model._meta.model_name - permission_required = f'{app_label}.{action}_{model_name}' + 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): diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index dc17548a2..539fe3057 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,5 +1,6 @@ import csv +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import ForeignKey @@ -64,8 +65,15 @@ class ViewTestCases: def test_get_object_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self._get_queryset().first().get_absolute_url()) - self.assertHttpStatus(response, 200) + ct = ContentType.objects.get_for_model(self.model) + if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: + # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users + with disable_warnings('django.request'): + response = self.client.get(self._get_queryset().first().get_absolute_url()) + self.assertHttpStatus(response, 302) + else: + response = self.client.get(self._get_queryset().first().get_absolute_url()) + self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_without_permission(self): @@ -128,6 +136,7 @@ class ViewTestCases: :form_data: Data to be used when creating a new object. """ form_data = {} + validation_excluded_fields = [] def test_create_object_without_permission(self): @@ -146,7 +155,6 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): - initial_count = self._get_queryset().count() # Assign unconstrained permission obj_perm = ObjectPermission( @@ -161,6 +169,7 @@ class ViewTestCases: 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), @@ -168,19 +177,19 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self._get_queryset().count()) instance = self._get_queryset().order_by('pk').last() - self.assertInstanceEqual(instance, self.form_data) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation - objectchanges = ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk - ) - self.assertEqual(len(objectchanges), 1) - self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) + if issubclass(instance.__class__, ChangeLoggingMixin): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE) @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( @@ -196,6 +205,7 @@ class ViewTestCases: 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), @@ -214,7 +224,8 @@ class ViewTestCases: } 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) + instance = self._get_queryset().order_by('pk').last() + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) class EditObjectViewTestCase(ModelViewTestCase): """ @@ -223,6 +234,7 @@ class ViewTestCases: :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() @@ -261,15 +273,17 @@ class ViewTestCases: '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) + instance = self._get_queryset().get(pk=instance.pk) + self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields) # Verify ObjectChange creation - objectchanges = ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk - ) - self.assertEqual(len(objectchanges), 1) - self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) + if issubclass(instance.__class__, ChangeLoggingMixin): + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk + ) + self.assertEqual(len(objectchanges), 1) + self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): @@ -297,7 +311,8 @@ class ViewTestCases: '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) + 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 = { @@ -404,8 +419,15 @@ class ViewTestCases: def test_list_objects_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) + ct = ContentType.objects.get_for_model(self.model) + if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS: + # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users + with disable_warnings('django.request'): + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 302) + else: + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_without_permission(self): @@ -450,10 +472,19 @@ class ViewTestCases: self.assertIn(instance1.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + @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) @@ -700,7 +731,7 @@ class ViewTestCases: # Assign model-level permission obj_perm = ObjectPermission( name='Test permission', - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -731,7 +762,7 @@ class ViewTestCases: obj_perm = ObjectPermission( name='Test permission', constraints={attr_name: value}, - actions=['change'] + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -795,7 +826,6 @@ class ViewTestCases: @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, @@ -814,6 +844,7 @@ class ViewTestCases: 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) self.assertEqual(self._get_queryset().count(), initial_count)