Quellcode durchsuchen

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 <jstretch@netboxlabs.com>
Arthur Hanson vor 2 Jahren
Ursprung
Commit
a4acb50edd

+ 51 - 0
netbox/netbox/navigation/menu.py

@@ -1,6 +1,7 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from netbox.registry import registry
 from netbox.registry import registry
+from utilities.choices import ButtonColorChoices
 from . import *
 from . import *
 
 
 #
 #
@@ -351,6 +352,56 @@ ADMIN_MENU = Menu(
     label=_('Admin'),
     label=_('Admin'),
     icon_class='mdi mdi-account-multiple',
     icon_class='mdi mdi-account-multiple',
     groups=(
     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(
         MenuGroup(
             label=_('Configuration'),
             label=_('Configuration'),
             items=(
             items=(

+ 1 - 0
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.
         Return a tuple of actions for which the given user is permitted to do.
         """
         """
         model = model or self.queryset.model
         model = model or self.queryset.model
+
         return [
         return [
             action for action in self.actions if user.has_perms([
             action for action in self.actions if user.has_perms([
                 get_permission_for_model(model, name) for name in self.action_perms[action]
                 get_permission_for_model(model, name) for name in self.action_perms[action]

+ 0 - 0
netbox/templates/users/api_token.html → netbox/templates/users/account/api_token.html


+ 1 - 1
netbox/templates/users/api_tokens.html → netbox/templates/users/account/api_tokens.html

@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 

+ 6 - 5
netbox/templates/users/base.html → netbox/templates/users/account/base.html

@@ -1,23 +1,24 @@
 {% extends 'base/layout.html' %}
 {% extends 'base/layout.html' %}
+{% load i18n %}
 
 
 {% block tabs %}
 {% block tabs %}
   <ul class="nav nav-tabs px-3">
   <ul class="nav nav-tabs px-3">
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
+      <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">{% trans "Profile" %}</a>
     </li>
     </li>
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">Bookmarks</a>
+      <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">{% trans "Bookmarks" %}</a>
     </li>
     </li>
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a>
+      <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">{% trans "Preferences" %}</a>
     </li>
     </li>
     {% if not request.user.ldap_username %}
     {% if not request.user.ldap_username %}
       <li role="presentation" class="nav-item">
       <li role="presentation" class="nav-item">
-        <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">Password</a>
+        <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">{% trans "Password" %}</a>
       </li>
       </li>
     {% endif %}
     {% endif %}
     <li role="presentation" class="nav-item">
     <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">API Tokens</a>
+      <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">{% trans "API Tokens" %}</a>
     </li>
     </li>
   </ul>
   </ul>
 {% endblock %}
 {% endblock %}

+ 1 - 1
netbox/templates/users/bookmarks.html → netbox/templates/users/account/bookmarks.html

@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
 {% load buttons %}
 {% load buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}

+ 1 - 1
netbox/templates/users/password.html → netbox/templates/users/account/password.html

@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Change Password{% endblock %}
 {% block title %}Change Password{% endblock %}

+ 1 - 1
netbox/templates/users/preferences.html → netbox/templates/users/account/preferences.html

@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
 {% load helpers %}
 {% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 

+ 1 - 1
netbox/templates/users/profile.html → netbox/templates/users/account/profile.html

@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
 {% load helpers %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 
 

+ 48 - 0
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 %}
+  <div class="row mb-3">
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Group" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Users" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for user in object.user_set.all %}
+            <a href="{% url 'users:netboxuser' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Assigned Permissions" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for perm in object.object_permissions.all %}
+            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 97 - 0
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 %}
+  <div class="row mb-3">
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Permission" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Enabled" %}</th>
+              <td>{% checkmark object.enabled %}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Actions" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "View" %}</th>
+              <td>{% checkmark object.can_view %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Add" %}</th>
+              <td>{% checkmark object.can_add %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Change" %}</th>
+              <td>{% checkmark object.can_change %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Delete" %}</th>
+              <td>{% checkmark object.can_delete %}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Constraints" %}</h5>
+        <div class="card-body">
+          {% if object.constraints %}
+            <pre>{{ object.constraints|json }}</pre>
+          {% else %}
+            <span class="text-muted">None</span>
+          {% endif %}
+        </div>
+      </div>
+    </div>
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Object Types" %}</h5>
+        <ul class="list-group list-group-flush">
+          {% for user in object.object_types.all %}
+            <li class="list-group-item">{{ user }}</li>
+          {% endfor %}
+        </ul>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Assigned Users" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for user in object.users.all %}
+            <a href="{% url 'users:netboxuser' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for group in object.groups.all %}
+            <a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 84 - 0
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 %}
+  <div class="row mb-3">
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "User" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Username" %}</th>
+              <td>{{ object.username }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Full Name" %}</th>
+              <td>{{ object.get_full_name|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Email" %}</th>
+              <td>{{ object.email|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Account Created" %}</th>
+              <td>{{ object.date_joined|annotated_date }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Active" %}</th>
+              <td>{% checkmark object.active %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Staff" %}</th>
+              <td>{% checkmark object.is_staff %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Superuser" %}</th>
+              <td>{% checkmark object.is_superuser %}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Assigned Groups" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for group in object.groups.all %}
+            <a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "Assigned Permissions" %}</h5>
+        <div class="list-group list-group-flush">
+          {% for perm in object.object_permissions.all %}
+            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
+          {% empty %}
+            <div class="list-group-item text-muted">{% trans "None" %}</div>
+          {% endfor %}
+        </div>
+      </div>
+    </div>
+  </div>
+  {% if perms.extras.view_objectchange %}
+    <div class="row">
+      <div class="col-md-12">
+        <div class="card">
+          <h5 class="card-header text-center">{% trans "Recent Activity" %}</h5>
+          <div class="card-body table-responsive">
+            {% render_table changelog_table 'inc/table.html' %}
+          </div>
+        </div>
+      </div>
+    </div>
+  {% endif %}
+{% endblock %}

+ 0 - 98
netbox/users/admin/__init__.py

@@ -15,41 +15,6 @@ admin.site.unregister(Group)
 admin.site.unregister(User)
 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
 # REST API tokens
 #
 #
@@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin):
     def list_allowed_ips(self, obj):
     def list_allowed_ips(self, obj):
         return obj.allowed_ips or 'Any'
         return obj.allowed_ips or 'Any'
     list_allowed_ips.short_description = "Allowed IPs"
     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")

+ 1 - 116
netbox/users/admin/forms.py

@@ -1,49 +1,13 @@
 from django import forms
 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 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__ = (
 __all__ = (
-    'GroupAdminForm',
-    'ObjectPermissionForm',
     'TokenAdminForm',
     '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):
 class TokenAdminForm(forms.ModelForm):
     key = forms.CharField(
     key = forms.CharField(
         required=False,
         required=False,
@@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm):
             'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
             'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
         ]
         ]
         model = Token
         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}'
-                    })

+ 20 - 1
netbox/users/filtersets.py

@@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = get_user_model()
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -115,6 +115,18 @@ class ObjectPermissionFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('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(
     user_id = django_filters.ModelMultipleChoiceFilter(
         field_name='users',
         field_name='users',
         queryset=get_user_model().objects.all(),
         queryset=get_user_model().objects.all(),
@@ -149,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet):
             Q(name__icontains=value) |
             Q(name__icontains=value) |
             Q(description__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])

+ 0 - 130
netbox/users/forms.py

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

+ 5 - 0
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 *

+ 25 - 0
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

+ 72 - 0
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',)

+ 32 - 0
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)

+ 111 - 0
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'),
+    )

+ 381 - 0
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}<br />' if preference.description else ''
+            help_text = f'{description}<code>{field_name}</code>'
+            field_kwargs = {
+                'label': preference.label,
+                'choices': preference.choices,
+                'help_text': mark_safe(help_text),
+                'coerce': preference.coerce,
+                'required': False,
+                'widget': forms.Select,
+            }
+            preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
+        attrs.update(preference_fields)
+
+        return super().__new__(mcs, name, bases, attrs)
+
+
+class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
+    fieldsets = (
+        (_('User Interface'), (
+            'pagination.per_page',
+            'pagination.placement',
+            'ui.colormode',
+        )),
+        (_('Miscellaneous'), (
+            'data_format',
+        )),
+    )
+    # List of clearable preferences
+    pk = forms.MultipleChoiceField(
+        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: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'),
+    )
+
+    class Meta:
+        model = Token
+        fields = [
+            'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
+        ]
+        widgets = {
+            'expires': DateTimePicker(),
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Omit the key field if token retrieval is not permitted
+        if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
+            del self.fields['key']
+
+
+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)
+                    })

+ 50 - 0
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'},
+        ),
+    ]

+ 62 - 1
netbox/users/models.py

@@ -2,13 +2,14 @@ import binascii
 import os
 import os
 
 
 from django.conf import settings
 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.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.validators import MinLengthValidator
 from django.core.validators import MinLengthValidator
 from django.db import models
 from django.db import models
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
+from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from netaddr import IPNetwork
 from netaddr import IPNetwork
@@ -20,6 +21,8 @@ from utilities.utils import flatten_dict
 from .constants import *
 from .constants import *
 
 
 __all__ = (
 __all__ = (
+    'NetBoxGroup',
+    'NetBoxUser',
     'ObjectPermission',
     'ObjectPermission',
     'Token',
     'Token',
     'UserConfig',
     'UserConfig',
@@ -30,6 +33,7 @@ __all__ = (
 # Proxy models for admin
 # Proxy models for admin
 #
 #
 
 
+
 class AdminGroup(Group):
 class AdminGroup(Group):
     """
     """
     Proxy contrib.auth.models.Group for the admin UI
     Proxy contrib.auth.models.Group for the admin UI
@@ -48,6 +52,44 @@ class AdminUser(User):
         proxy = True
         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
 # User preferences
 #
 #
@@ -325,6 +367,22 @@ class ObjectPermission(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         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):
     def list_constraints(self):
         """
         """
         Return all constraint sets as a list (even if only a single set is defined).
         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:
         if type(self.constraints) is not list:
             return [self.constraints]
             return [self.constraints]
         return self.constraints
         return self.constraints
+
+    def get_absolute_url(self):
+        return reverse('users:objectpermission', args=[self.pk])

+ 76 - 1
netbox/users/tables.py

@@ -1,8 +1,14 @@
-from .models import Token
+import django_tables2 as tables
+
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
+from .models import Token
 
 
 __all__ = (
 __all__ = (
+    'GroupTable',
+    'ObjectPermissionTable',
     'TokenTable',
     'TokenTable',
+    'UserTable',
 )
 )
 
 
 
 
@@ -48,3 +54,72 @@ class TokenTable(NetBoxTable):
         fields = (
         fields = (
             'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
             'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
         )
         )
+
+
+class UserTable(NetBoxTable):
+    username = tables.Column(
+        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',
+        )

+ 25 - 5
netbox/users/tests/test_filtersets.py

@@ -10,7 +10,6 @@ from users import filtersets
 from users.models import ObjectPermission, Token
 from users.models import ObjectPermission, Token
 from utilities.testing import BaseFilterSetTests
 from utilities.testing import BaseFilterSetTests
 
 
-
 User = get_user_model()
 User = get_user_model()
 
 
 
 
@@ -34,7 +33,8 @@ class UserTestCase(TestCase, BaseFilterSetTests):
                 first_name='Hank',
                 first_name='Hank',
                 last_name='Hill',
                 last_name='Hill',
                 email='hank@stricklandpropane.com',
                 email='hank@stricklandpropane.com',
-                is_staff=True
+                is_staff=True,
+                is_superuser=True
             ),
             ),
             User(
             User(
                 username='User2',
                 username='User2',
@@ -83,13 +83,17 @@ class UserTestCase(TestCase, BaseFilterSetTests):
         params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']}
         params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_is_staff(self):
         params = {'is_staff': True}
         params = {'is_staff': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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):
     def test_group(self):
         groups = Group.objects.all()[:2]
         groups = Group.objects.all()[:2]
@@ -191,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class TokenTestCase(TestCase, BaseFilterSetTests):
     queryset = Token.objects.all()
     queryset = Token.objects.all()

+ 151 - 0
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',
+        }

+ 23 - 3
netbox/users/urls.py

@@ -6,15 +6,35 @@ from . import views
 app_name = 'users'
 app_name = 'users'
 urlpatterns = [
 urlpatterns = [
 
 
-    # User
+    # Account views
     path('profile/', views.ProfileView.as_view(), name='profile'),
     path('profile/', views.ProfileView.as_view(), name='profile'),
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
-
-    # API tokens
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
     path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
     path('api-tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
     path('api-tokens/<int:pk>/', 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/<int:pk>/', 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/<int:pk>/', 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/<int:pk>/', include(get_model_urls('users', 'objectpermission'))),
+
 ]
 ]

+ 156 - 21
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.mixins import LoginRequiredMixin
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
 from django.contrib.auth.signals import user_logged_in
+from django.db.models import Count
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.urls import reverse
 from django.urls import reverse
@@ -19,12 +20,11 @@ from extras.models import Bookmark, ObjectChange
 from extras.tables import BookmarkTable, ObjectChangeTable
 from extras.tables import BookmarkTable, ObjectChangeTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
 from netbox.config import get_config
-from netbox.views.generic import ObjectListView
+from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.views import register_model_view
 from utilities.views import register_model_view
-from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
-from .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
         return auth_backends
 
 
     def get(self, request):
     def get(self, request):
-        form = LoginForm(request)
+        form = forms.LoginForm(request)
 
 
         if request.user.is_authenticated:
         if request.user.is_authenticated:
             logger = logging.getLogger('netbox.auth.login')
             logger = logging.getLogger('netbox.auth.login')
@@ -83,7 +83,7 @@ class LoginView(View):
 
 
     def post(self, request):
     def post(self, request):
         logger = logging.getLogger('netbox.auth.login')
         logger = logging.getLogger('netbox.auth.login')
-        form = LoginForm(request, data=request.POST)
+        form = forms.LoginForm(request, data=request.POST)
 
 
         if form.is_valid():
         if form.is_valid():
             logger.debug("Login form validation was successful")
             logger.debug("Login form validation was successful")
@@ -155,7 +155,7 @@ class LogoutView(View):
 #
 #
 
 
 class ProfileView(LoginRequiredMixin, View):
 class ProfileView(LoginRequiredMixin, View):
-    template_name = 'users/profile.html'
+    template_name = 'users/account/profile.html'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -174,11 +174,11 @@ class ProfileView(LoginRequiredMixin, View):
 
 
 
 
 class UserConfigView(LoginRequiredMixin, View):
 class UserConfigView(LoginRequiredMixin, View):
-    template_name = 'users/preferences.html'
+    template_name = 'users/account/preferences.html'
 
 
     def get(self, request):
     def get(self, request):
         userconfig = request.user.config
         userconfig = request.user.config
-        form = UserConfigForm(instance=userconfig)
+        form = forms.UserConfigForm(instance=userconfig)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
@@ -187,7 +187,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
 
     def post(self, request):
     def post(self, request):
         userconfig = request.user.config
         userconfig = request.user.config
-        form = UserConfigForm(request.POST, instance=userconfig)
+        form = forms.UserConfigForm(request.POST, instance=userconfig)
 
 
         if form.is_valid():
         if form.is_valid():
             form.save()
             form.save()
@@ -202,7 +202,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
 
 
 
 class ChangePasswordView(LoginRequiredMixin, View):
 class ChangePasswordView(LoginRequiredMixin, View):
-    template_name = 'users/password.html'
+    template_name = 'users/account/password.html'
 
 
     def get(self, request):
     def get(self, request):
         # LDAP users cannot change their password here
         # 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.")
             messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
             return redirect('users:profile')
             return redirect('users:profile')
 
 
-        form = PasswordChangeForm(user=request.user)
+        form = forms.PasswordChangeForm(user=request.user)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
@@ -218,7 +218,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
         })
         })
 
 
     def post(self, request):
     def post(self, request):
-        form = PasswordChangeForm(user=request.user, data=request.POST)
+        form = forms.PasswordChangeForm(user=request.user, data=request.POST)
         if form.is_valid():
         if form.is_valid():
             form.save()
             form.save()
             update_session_auth_hash(request, form.user)
             update_session_auth_hash(request, form.user)
@@ -235,9 +235,9 @@ class ChangePasswordView(LoginRequiredMixin, View):
 # Bookmarks
 # Bookmarks
 #
 #
 
 
-class BookmarkListView(LoginRequiredMixin, ObjectListView):
+class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
     table = BookmarkTable
     table = BookmarkTable
-    template_name = 'users/bookmarks.html'
+    template_name = 'users/account/bookmarks.html'
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         return Bookmark.objects.filter(user=request.user)
         return Bookmark.objects.filter(user=request.user)
@@ -257,10 +257,10 @@ class TokenListView(LoginRequiredMixin, View):
     def get(self, request):
     def get(self, request):
 
 
         tokens = Token.objects.filter(user=request.user)
         tokens = Token.objects.filter(user=request.user)
-        table = TokenTable(tokens)
+        table = tables.TokenTable(tokens)
         table.configure(request)
         table.configure(request)
 
 
-        return render(request, 'users/api_tokens.html', {
+        return render(request, 'users/account/api_tokens.html', {
             'tokens': tokens,
             'tokens': tokens,
             'active_tab': 'api-tokens',
             'active_tab': 'api-tokens',
             'table': table,
             'table': table,
@@ -277,7 +277,7 @@ class TokenEditView(LoginRequiredMixin, View):
         else:
         else:
             token = Token(user=request.user)
             token = Token(user=request.user)
 
 
-        form = TokenForm(instance=token)
+        form = forms.TokenForm(instance=token)
 
 
         return render(request, 'generic/object_edit.html', {
         return render(request, 'generic/object_edit.html', {
             'object': token,
             'object': token,
@@ -289,10 +289,10 @@ class TokenEditView(LoginRequiredMixin, View):
 
 
         if pk:
         if pk:
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
-            form = TokenForm(request.POST, instance=token)
+            form = forms.TokenForm(request.POST, instance=token)
         else:
         else:
             token = Token(user=request.user)
             token = Token(user=request.user)
-            form = TokenForm(request.POST)
+            form = forms.TokenForm(request.POST)
 
 
         if form.is_valid():
         if form.is_valid():
 
 
@@ -304,7 +304,7 @@ class TokenEditView(LoginRequiredMixin, View):
             messages.success(request, msg)
             messages.success(request, msg)
 
 
             if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
             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,
                     'object': token,
                     'key': token.key,
                     'key': token.key,
                     'return_url': reverse('users:token_list'),
                     'return_url': reverse('users:token_list'),
@@ -353,3 +353,138 @@ class TokenDeleteView(LoginRequiredMixin, View):
             'form': form,
             'form': form,
             'return_url': reverse('users:token_list'),
             '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

+ 4 - 5
netbox/utilities/permissions.py

@@ -18,11 +18,10 @@ def get_permission_for_model(model, action):
     :param model: A model or instance
     :param model: A model or instance
     :param action: View, add, change, or delete (string)
     :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):
 def resolve_permission(name):

+ 2 - 4
netbox/utilities/querysets.py

@@ -1,7 +1,7 @@
 from django.db.models import Prefetch, QuerySet
 from django.db.models import Prefetch, QuerySet
 
 
 from users.constants import CONSTRAINT_TOKEN_USER
 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__ = (
 __all__ = (
     'RestrictedPrefetch',
     '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'
         :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
         # 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
         # Bypass restriction for superusers and exempt views
         if user.is_superuser or permission_is_exempt(permission_required):
         if user.is_superuser or permission_is_exempt(permission_required):

+ 57 - 26
netbox/utilities/testing/views.py

@@ -1,5 +1,6 @@
 import csv
 import csv
 
 
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import ForeignKey
 from django.db.models import ForeignKey
@@ -64,8 +65,15 @@ class ViewTestCases:
         def test_get_object_anonymous(self):
         def test_get_object_anonymous(self):
             # Make the request as an unauthenticated user
             # Make the request as an unauthenticated user
             self.client.logout()
             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=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_get_object_without_permission(self):
         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: Data to be used when creating a new object.
         """
         """
         form_data = {}
         form_data = {}
+        validation_excluded_fields = []
 
 
         def test_create_object_without_permission(self):
         def test_create_object_without_permission(self):
 
 
@@ -146,7 +155,6 @@ class ViewTestCases:
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_create_object_with_permission(self):
         def test_create_object_with_permission(self):
-            initial_count = self._get_queryset().count()
 
 
             # Assign unconstrained permission
             # Assign unconstrained permission
             obj_perm = ObjectPermission(
             obj_perm = ObjectPermission(
@@ -161,6 +169,7 @@ class ViewTestCases:
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
 
 
             # Try POST with model-level permission
             # Try POST with model-level permission
+            initial_count = self._get_queryset().count()
             request = {
             request = {
                 'path': self._get_url('add'),
                 'path': self._get_url('add'),
                 'data': post_data(self.form_data),
                 'data': post_data(self.form_data),
@@ -168,19 +177,19 @@ class ViewTestCases:
             self.assertHttpStatus(self.client.post(**request), 302)
             self.assertHttpStatus(self.client.post(**request), 302)
             self.assertEqual(initial_count + 1, self._get_queryset().count())
             self.assertEqual(initial_count + 1, self._get_queryset().count())
             instance = self._get_queryset().order_by('pk').last()
             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
             # 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=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_create_object_with_constrained_permission(self):
         def test_create_object_with_constrained_permission(self):
-            initial_count = self._get_queryset().count()
 
 
             # Assign constrained permission
             # Assign constrained permission
             obj_perm = ObjectPermission(
             obj_perm = ObjectPermission(
@@ -196,6 +205,7 @@ class ViewTestCases:
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
             self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
 
 
             # Try to create an object (not permitted)
             # Try to create an object (not permitted)
+            initial_count = self._get_queryset().count()
             request = {
             request = {
                 'path': self._get_url('add'),
                 'path': self._get_url('add'),
                 'data': post_data(self.form_data),
                 'data': post_data(self.form_data),
@@ -214,7 +224,8 @@ class ViewTestCases:
             }
             }
             self.assertHttpStatus(self.client.post(**request), 302)
             self.assertHttpStatus(self.client.post(**request), 302)
             self.assertEqual(initial_count + 1, self._get_queryset().count())
             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):
     class EditObjectViewTestCase(ModelViewTestCase):
         """
         """
@@ -223,6 +234,7 @@ class ViewTestCases:
         :form_data: Data to be used when updating the first existing object.
         :form_data: Data to be used when updating the first existing object.
         """
         """
         form_data = {}
         form_data = {}
+        validation_excluded_fields = []
 
 
         def test_edit_object_without_permission(self):
         def test_edit_object_without_permission(self):
             instance = self._get_queryset().first()
             instance = self._get_queryset().first()
@@ -261,15 +273,17 @@ class ViewTestCases:
                 'data': post_data(self.form_data),
                 'data': post_data(self.form_data),
             }
             }
             self.assertHttpStatus(self.client.post(**request), 302)
             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
             # 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=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_edit_object_with_constrained_permission(self):
         def test_edit_object_with_constrained_permission(self):
@@ -297,7 +311,8 @@ class ViewTestCases:
                 'data': post_data(self.form_data),
                 'data': post_data(self.form_data),
             }
             }
             self.assertHttpStatus(self.client.post(**request), 302)
             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
             # Try to edit a non-permitted object
             request = {
             request = {
@@ -404,8 +419,15 @@ class ViewTestCases:
         def test_list_objects_anonymous(self):
         def test_list_objects_anonymous(self):
             # Make the request as an unauthenticated user
             # Make the request as an unauthenticated user
             self.client.logout()
             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=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_list_objects_without_permission(self):
         def test_list_objects_without_permission(self):
@@ -450,10 +472,19 @@ class ViewTestCases:
             self.assertIn(instance1.get_absolute_url(), content)
             self.assertIn(instance1.get_absolute_url(), content)
             self.assertNotIn(instance2.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):
         def test_export_objects(self):
             url = self._get_url('list')
             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
             # Test default CSV export
             response = self.client.get(f'{url}?export')
             response = self.client.get(f'{url}?export')
             self.assertHttpStatus(response, 200)
             self.assertHttpStatus(response, 200)
@@ -700,7 +731,7 @@ class ViewTestCases:
             # Assign model-level permission
             # Assign model-level permission
             obj_perm = ObjectPermission(
             obj_perm = ObjectPermission(
                 name='Test permission',
                 name='Test permission',
-                actions=['change']
+                actions=['view', 'change']
             )
             )
             obj_perm.save()
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
@@ -731,7 +762,7 @@ class ViewTestCases:
             obj_perm = ObjectPermission(
             obj_perm = ObjectPermission(
                 name='Test permission',
                 name='Test permission',
                 constraints={attr_name: value},
                 constraints={attr_name: value},
-                actions=['change']
+                actions=['view', 'change']
             )
             )
             obj_perm.save()
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
@@ -795,7 +826,6 @@ class ViewTestCases:
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_bulk_delete_objects_with_constrained_permission(self):
         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)
             pk_list = self._get_queryset().values_list('pk', flat=True)
             data = {
             data = {
                 'pk': pk_list,
                 'pk': pk_list,
@@ -814,6 +844,7 @@ class ViewTestCases:
             obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
             obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
 
 
             # Attempt to bulk delete non-permitted objects
             # 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.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
             self.assertEqual(self._get_queryset().count(), initial_count)
             self.assertEqual(self._get_queryset().count(), initial_count)