Explorar o código

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 %!s(int64=2) %!d(string=hai) anos
pai
achega
a4acb50edd

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

@@ -1,6 +1,7 @@
 from django.utils.translation import gettext as _
 
 from netbox.registry import registry
+from utilities.choices import ButtonColorChoices
 from . import *
 
 #
@@ -351,6 +352,56 @@ ADMIN_MENU = Menu(
     label=_('Admin'),
     icon_class='mdi mdi-account-multiple',
     groups=(
+        MenuGroup(
+            label=_('Users'),
+            items=(
+                # Proxy model for auth.User
+                MenuItem(
+                    link=f'users:netboxuser_list',
+                    link_text=_('Users'),
+                    permissions=[f'auth.view_user'],
+                    buttons=(
+                        MenuItemButton(
+                            link=f'users:netboxuser_add',
+                            title='Add',
+                            icon_class='mdi mdi-plus-thick',
+                            permissions=[f'auth.add_user'],
+                            color=ButtonColorChoices.GREEN
+                        ),
+                        MenuItemButton(
+                            link=f'users:netboxuser_import',
+                            title='Import',
+                            icon_class='mdi mdi-upload',
+                            permissions=[f'auth.add_user'],
+                            color=ButtonColorChoices.CYAN
+                        )
+                    )
+                ),
+                # Proxy model for auth.Group
+                MenuItem(
+                    link=f'users:netboxgroup_list',
+                    link_text=_('Groups'),
+                    permissions=[f'auth.view_group'],
+                    buttons=(
+                        MenuItemButton(
+                            link=f'users:netboxgroup_add',
+                            title='Add',
+                            icon_class='mdi mdi-plus-thick',
+                            permissions=[f'auth.add_group'],
+                            color=ButtonColorChoices.GREEN
+                        ),
+                        MenuItemButton(
+                            link=f'users:netboxgroup_import',
+                            title='Import',
+                            icon_class='mdi mdi-upload',
+                            permissions=[f'auth.add_group'],
+                            color=ButtonColorChoices.CYAN
+                        )
+                    )
+                ),
+                get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
+            ),
+        ),
         MenuGroup(
             label=_('Configuration'),
             items=(

+ 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.
         """
         model = model or self.queryset.model
+
         return [
             action for action in self.actions if user.has_perms([
                 get_permission_for_model(model, name) for name in self.action_perms[action]

+ 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 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' %}
+{% load i18n %}
 
 {% block tabs %}
   <ul class="nav nav-tabs px-3">
     <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 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 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>
     {% if not request.user.ldap_username %}
       <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>
     {% endif %}
     <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>
   </ul>
 {% 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 helpers %}
 {% 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 %}
 
 {% 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 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 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.register(Group)
-class GroupAdmin(admin.ModelAdmin):
-    form = forms.GroupAdminForm
-    list_display = ('name', 'user_count')
-    ordering = ('name',)
-    search_fields = ('name',)
-    inlines = [inlines.GroupObjectPermissionInline]
-
-    @staticmethod
-    def user_count(obj):
-        return obj.user_set.count()
-
-
-@admin.register(User)
-class UserAdmin(UserAdmin_):
-    list_display = [
-        'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
-    ]
-    fieldsets = (
-        (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
-        ('Groups', {'fields': ('groups',)}),
-        ('Status', {
-            'fields': ('is_active', 'is_staff', 'is_superuser'),
-        }),
-        ('Important dates', {'fields': ('last_login', 'date_joined')}),
-    )
-    filter_horizontal = ('groups',)
-    list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
-
-    def get_inlines(self, request, obj):
-        if obj is not None:
-            return (inlines.UserObjectPermissionInline, inlines.UserConfigInline)
-        return ()
-
-
 #
 # REST API tokens
 #
@@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin):
     def list_allowed_ips(self, obj):
         return obj.allowed_ips or 'Any'
     list_allowed_ips.short_description = "Allowed IPs"
-
-
-#
-# Permissions
-#
-
-@admin.register(ObjectPermission)
-class ObjectPermissionAdmin(admin.ModelAdmin):
-    actions = ('enable', 'disable')
-    fieldsets = (
-        (None, {
-            'fields': ('name', 'description', 'enabled')
-        }),
-        ('Actions', {
-            'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
-        }),
-        ('Objects', {
-            'fields': ('object_types',)
-        }),
-        ('Assignment', {
-            'fields': ('groups', 'users')
-        }),
-        ('Constraints', {
-            'fields': ('constraints',),
-            'classes': ('monospace',)
-        }),
-    )
-    filter_horizontal = ('object_types', 'groups', 'users')
-    form = forms.ObjectPermissionForm
-    list_display = [
-        'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
-    ]
-    list_filter = [
-        'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users'
-    ]
-    search_fields = ['actions', 'constraints', 'description', 'name']
-
-    def get_queryset(self, request):
-        return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
-
-    def list_models(self, obj):
-        return ', '.join([f"{ct}" for ct in obj.object_types.all()])
-    list_models.short_description = 'Models'
-
-    def list_users(self, obj):
-        return ', '.join([u.username for u in obj.users.all()])
-    list_users.short_description = 'Users'
-
-    def list_groups(self, obj):
-        return ', '.join([g.name for g in obj.groups.all()])
-    list_groups.short_description = 'Groups'
-
-    #
-    # Admin actions
-    #
-
-    def enable(self, request, queryset):
-        updated = queryset.update(enabled=True)
-        self.message_user(request, f"Enabled {updated} permissions")
-
-    def disable(self, request, queryset):
-        updated = queryset.update(enabled=False)
-        self.message_user(request, f"Disabled {updated} permissions")

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

@@ -1,49 +1,13 @@
 from django import forms
-from django.contrib.auth.models import Group, User
-from django.contrib.admin.widgets import FilteredSelectMultiple
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, ValidationError
 from django.utils.translation import gettext as _
 
-from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
-from users.models import ObjectPermission, Token
-from utilities.forms.fields import ContentTypeMultipleChoiceField
-from utilities.permissions import qs_filter_from_constraints
+from users.models import Token
 
 __all__ = (
-    'GroupAdminForm',
-    'ObjectPermissionForm',
     'TokenAdminForm',
 )
 
 
-class GroupAdminForm(forms.ModelForm):
-    users = forms.ModelMultipleChoiceField(
-        queryset=User.objects.all(),
-        required=False,
-        widget=FilteredSelectMultiple('users', False)
-    )
-
-    class Meta:
-        model = Group
-        fields = ('name', 'users')
-
-    def __init__(self, *args, **kwargs):
-        super(GroupAdminForm, self).__init__(*args, **kwargs)
-
-        if self.instance.pk:
-            self.fields['users'].initial = self.instance.user_set.all()
-
-    def save_m2m(self):
-        self.instance.user_set.set(self.cleaned_data['users'])
-
-    def save(self, *args, **kwargs):
-        instance = super(GroupAdminForm, self).save()
-        self.save_m2m()
-
-        return instance
-
-
 class TokenAdminForm(forms.ModelForm):
     key = forms.CharField(
         required=False,
@@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm):
             'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
         ]
         model = Token
-
-
-class ObjectPermissionForm(forms.ModelForm):
-    object_types = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
-        limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
-    )
-    can_view = forms.BooleanField(required=False)
-    can_add = forms.BooleanField(required=False)
-    can_change = forms.BooleanField(required=False)
-    can_delete = forms.BooleanField(required=False)
-
-    class Meta:
-        model = ObjectPermission
-        exclude = []
-        help_texts = {
-            'actions': _('Actions granted in addition to those listed above'),
-            'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null '
-                             'to match all objects of this type. A list of multiple objects will result in a logical OR '
-                             'operation.')
-        }
-        labels = {
-            'actions': 'Additional actions'
-        }
-        widgets = {
-            'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
-        }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Make the actions field optional since the admin form uses it only for non-CRUD actions
-        self.fields['actions'].required = False
-
-        # Order group and user fields
-        self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
-        self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
-
-        # Check the appropriate checkboxes when editing an existing ObjectPermission
-        if self.instance.pk:
-            for action in ['view', 'add', 'change', 'delete']:
-                if action in self.instance.actions:
-                    self.fields[f'can_{action}'].initial = True
-                    self.instance.actions.remove(action)
-
-    def clean(self):
-        super().clean()
-
-        object_types = self.cleaned_data.get('object_types')
-        constraints = self.cleaned_data.get('constraints')
-
-        # Append any of the selected CRUD checkboxes to the actions list
-        if not self.cleaned_data.get('actions'):
-            self.cleaned_data['actions'] = list()
-        for action in ['view', 'add', 'change', 'delete']:
-            if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
-                self.cleaned_data['actions'].append(action)
-
-        # At least one action must be specified
-        if not self.cleaned_data['actions']:
-            raise ValidationError("At least one action must be selected.")
-
-        # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
-        # returns anything; we just want to make sure the specified constraints are valid.
-        if object_types and constraints:
-            # Normalize the constraints to a list of dicts
-            if type(constraints) is not list:
-                constraints = [constraints]
-            for ct in object_types:
-                model = ct.model_class()
-                try:
-                    tokens = {
-                        CONSTRAINT_TOKEN_USER: 0,  # Replace token with a null user ID
-                    }
-                    model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
-                except FieldError as e:
-                    raise ValidationError({
-                        'constraints': f'Invalid filter for {model}: {e}'
-                    })

+ 20 - 1
netbox/users/filtersets.py

@@ -49,7 +49,7 @@ class UserFilterSet(BaseFilterSet):
 
     class Meta:
         model = get_user_model()
-        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
+        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -115,6 +115,18 @@ class ObjectPermissionFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
+    can_view = django_filters.BooleanFilter(
+        method='_check_action'
+    )
+    can_add = django_filters.BooleanFilter(
+        method='_check_action'
+    )
+    can_change = django_filters.BooleanFilter(
+        method='_check_action'
+    )
+    can_delete = django_filters.BooleanFilter(
+        method='_check_action'
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
         field_name='users',
         queryset=get_user_model().objects.all(),
@@ -149,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet):
             Q(name__icontains=value) |
             Q(description__icontains=value)
         )
+
+    def _check_action(self, queryset, name, value):
+        action = name.split('_')[1]
+        if value:
+            return queryset.filter(actions__contains=[action])
+        else:
+            return queryset.exclude(actions__contains=[action])

+ 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
 
 from django.conf import settings
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group, GroupManager, User, UserManager
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.core.validators import MinLengthValidator
 from django.db import models
 from django.db.models.signals import post_save
 from django.dispatch import receiver
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext as _
 from netaddr import IPNetwork
@@ -20,6 +21,8 @@ from utilities.utils import flatten_dict
 from .constants import *
 
 __all__ = (
+    'NetBoxGroup',
+    'NetBoxUser',
     'ObjectPermission',
     'Token',
     'UserConfig',
@@ -30,6 +33,7 @@ __all__ = (
 # Proxy models for admin
 #
 
+
 class AdminGroup(Group):
     """
     Proxy contrib.auth.models.Group for the admin UI
@@ -48,6 +52,44 @@ class AdminUser(User):
         proxy = True
 
 
+class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)):
+    pass
+
+
+class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
+    pass
+
+
+class NetBoxUser(User):
+    """
+    Proxy contrib.auth.models.User for the UI
+    """
+    objects = NetBoxUserManager()
+
+    class Meta:
+        verbose_name = 'User'
+        proxy = True
+        ordering = ('username',)
+
+    def get_absolute_url(self):
+        return reverse('users:netboxuser', args=[self.pk])
+
+
+class NetBoxGroup(Group):
+    """
+    Proxy contrib.auth.models.User for the UI
+    """
+    objects = NetBoxGroupManager()
+
+    class Meta:
+        verbose_name = 'Group'
+        proxy = True
+        ordering = ('name',)
+
+    def get_absolute_url(self):
+        return reverse('users:netboxgroup', args=[self.pk])
+
+
 #
 # User preferences
 #
@@ -325,6 +367,22 @@ class ObjectPermission(models.Model):
     def __str__(self):
         return self.name
 
+    @property
+    def can_view(self):
+        return 'view' in self.actions
+
+    @property
+    def can_add(self):
+        return 'add' in self.actions
+
+    @property
+    def can_change(self):
+        return 'change' in self.actions
+
+    @property
+    def can_delete(self):
+        return 'delete' in self.actions
+
     def list_constraints(self):
         """
         Return all constraint sets as a list (even if only a single set is defined).
@@ -332,3 +390,6 @@ class ObjectPermission(models.Model):
         if type(self.constraints) is not list:
             return [self.constraints]
         return self.constraints
+
+    def get_absolute_url(self):
+        return reverse('users:objectpermission', args=[self.pk])

+ 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 users.models import NetBoxGroup, NetBoxUser, ObjectPermission
+from .models import Token
 
 __all__ = (
+    'GroupTable',
+    'ObjectPermissionTable',
     'TokenTable',
+    'UserTable',
 )
 
 
@@ -48,3 +54,72 @@ class TokenTable(NetBoxTable):
         fields = (
             'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
         )
+
+
+class UserTable(NetBoxTable):
+    username = tables.Column(
+        linkify=True
+    )
+    groups = columns.ManyToManyColumn(
+        linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+    )
+    is_active = columns.BooleanColumn()
+    is_staff = columns.BooleanColumn()
+    is_superuser = columns.BooleanColumn()
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = NetBoxUser
+        fields = (
+            'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
+            'is_superuser',
+        )
+        default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
+
+
+class GroupTable(NetBoxTable):
+    name = tables.Column(linkify=True)
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = NetBoxGroup
+        fields = (
+            'pk', 'id', 'name', 'users_count',
+        )
+        default_columns = ('pk', 'name', 'users_count', )
+
+
+class ObjectPermissionTable(NetBoxTable):
+    name = tables.Column(linkify=True)
+    object_types = columns.ContentTypesColumn()
+    enabled = columns.BooleanColumn()
+    can_view = columns.BooleanColumn()
+    can_add = columns.BooleanColumn()
+    can_change = columns.BooleanColumn()
+    can_delete = columns.BooleanColumn()
+    custom_actions = columns.ArrayColumn(
+        accessor=tables.A('actions')
+    )
+    users = columns.ManyToManyColumn(
+        linkify_item=('users:netboxuser', {'pk': tables.A('pk')})
+    )
+    groups = columns.ManyToManyColumn(
+        linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+    )
+    actions = columns.ActionsColumn(
+        actions=('edit', 'delete'),
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ObjectPermission
+        fields = (
+            'pk', 'id', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete',
+            'custom_actions', 'users', 'groups', 'constraints', 'description',
+        )
+        default_columns = (
+            'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description',
+        )

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

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

+ 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'
 urlpatterns = [
 
-    # User
+    # Account views
     path('profile/', views.ProfileView.as_view(), name='profile'),
     path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
     path('preferences/', views.UserConfigView.as_view(), name='preferences'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
-
-    # API tokens
     path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
     path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
     path('api-tokens/<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.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
+from django.db.models import Count
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.urls import reverse
@@ -19,12 +20,11 @@ from extras.models import Bookmark, ObjectChange
 from extras.tables import BookmarkTable, ObjectChangeTable
 from netbox.authentication import get_auth_backend_display, get_saml_idps
 from netbox.config import get_config
-from netbox.views.generic import ObjectListView
+from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.views import register_model_view
-from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
-from .models import Token, UserConfig
-from .tables import TokenTable
+from . import filtersets, forms, tables
+from .models import Token, UserConfig, NetBoxGroup, NetBoxUser, ObjectPermission
 
 
 #
@@ -70,7 +70,7 @@ class LoginView(View):
         return auth_backends
 
     def get(self, request):
-        form = LoginForm(request)
+        form = forms.LoginForm(request)
 
         if request.user.is_authenticated:
             logger = logging.getLogger('netbox.auth.login')
@@ -83,7 +83,7 @@ class LoginView(View):
 
     def post(self, request):
         logger = logging.getLogger('netbox.auth.login')
-        form = LoginForm(request, data=request.POST)
+        form = forms.LoginForm(request, data=request.POST)
 
         if form.is_valid():
             logger.debug("Login form validation was successful")
@@ -155,7 +155,7 @@ class LogoutView(View):
 #
 
 class ProfileView(LoginRequiredMixin, View):
-    template_name = 'users/profile.html'
+    template_name = 'users/account/profile.html'
 
     def get(self, request):
 
@@ -174,11 +174,11 @@ class ProfileView(LoginRequiredMixin, View):
 
 
 class UserConfigView(LoginRequiredMixin, View):
-    template_name = 'users/preferences.html'
+    template_name = 'users/account/preferences.html'
 
     def get(self, request):
         userconfig = request.user.config
-        form = UserConfigForm(instance=userconfig)
+        form = forms.UserConfigForm(instance=userconfig)
 
         return render(request, self.template_name, {
             'form': form,
@@ -187,7 +187,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
     def post(self, request):
         userconfig = request.user.config
-        form = UserConfigForm(request.POST, instance=userconfig)
+        form = forms.UserConfigForm(request.POST, instance=userconfig)
 
         if form.is_valid():
             form.save()
@@ -202,7 +202,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
 
 class ChangePasswordView(LoginRequiredMixin, View):
-    template_name = 'users/password.html'
+    template_name = 'users/account/password.html'
 
     def get(self, request):
         # LDAP users cannot change their password here
@@ -210,7 +210,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
             messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
             return redirect('users:profile')
 
-        form = PasswordChangeForm(user=request.user)
+        form = forms.PasswordChangeForm(user=request.user)
 
         return render(request, self.template_name, {
             'form': form,
@@ -218,7 +218,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
         })
 
     def post(self, request):
-        form = PasswordChangeForm(user=request.user, data=request.POST)
+        form = forms.PasswordChangeForm(user=request.user, data=request.POST)
         if form.is_valid():
             form.save()
             update_session_auth_hash(request, form.user)
@@ -235,9 +235,9 @@ class ChangePasswordView(LoginRequiredMixin, View):
 # Bookmarks
 #
 
-class BookmarkListView(LoginRequiredMixin, ObjectListView):
+class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
     table = BookmarkTable
-    template_name = 'users/bookmarks.html'
+    template_name = 'users/account/bookmarks.html'
 
     def get_queryset(self, request):
         return Bookmark.objects.filter(user=request.user)
@@ -257,10 +257,10 @@ class TokenListView(LoginRequiredMixin, View):
     def get(self, request):
 
         tokens = Token.objects.filter(user=request.user)
-        table = TokenTable(tokens)
+        table = tables.TokenTable(tokens)
         table.configure(request)
 
-        return render(request, 'users/api_tokens.html', {
+        return render(request, 'users/account/api_tokens.html', {
             'tokens': tokens,
             'active_tab': 'api-tokens',
             'table': table,
@@ -277,7 +277,7 @@ class TokenEditView(LoginRequiredMixin, View):
         else:
             token = Token(user=request.user)
 
-        form = TokenForm(instance=token)
+        form = forms.TokenForm(instance=token)
 
         return render(request, 'generic/object_edit.html', {
             'object': token,
@@ -289,10 +289,10 @@ class TokenEditView(LoginRequiredMixin, View):
 
         if pk:
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
-            form = TokenForm(request.POST, instance=token)
+            form = forms.TokenForm(request.POST, instance=token)
         else:
             token = Token(user=request.user)
-            form = TokenForm(request.POST)
+            form = forms.TokenForm(request.POST)
 
         if form.is_valid():
 
@@ -304,7 +304,7 @@ class TokenEditView(LoginRequiredMixin, View):
             messages.success(request, msg)
 
             if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
-                return render(request, 'users/api_token.html', {
+                return render(request, 'users/account/api_token.html', {
                     'object': token,
                     'key': token.key,
                     'return_url': reverse('users:token_list'),
@@ -353,3 +353,138 @@ class TokenDeleteView(LoginRequiredMixin, View):
             'form': form,
             'return_url': reverse('users:token_list'),
         })
+
+#
+# Users
+#
+
+
+class UserListView(generic.ObjectListView):
+    queryset = NetBoxUser.objects.all()
+    filterset = filtersets.UserFilterSet
+    filterset_form = forms.UserFilterForm
+    table = tables.UserTable
+
+
+@register_model_view(NetBoxUser)
+class UserView(generic.ObjectView):
+    queryset = NetBoxUser.objects.all()
+    template_name = 'users/user.html'
+
+    def get_extra_context(self, request, instance):
+        changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20]
+        changelog_table = ObjectChangeTable(changelog)
+
+        return {
+            'changelog_table': changelog_table,
+        }
+
+
+@register_model_view(NetBoxUser, 'edit')
+class UserEditView(generic.ObjectEditView):
+    queryset = NetBoxUser.objects.all()
+    form = forms.UserForm
+
+
+@register_model_view(NetBoxUser, 'delete')
+class UserDeleteView(generic.ObjectDeleteView):
+    queryset = NetBoxUser.objects.all()
+
+
+class UserBulkEditView(generic.BulkEditView):
+    queryset = NetBoxUser.objects.all()
+    filterset = filtersets.UserFilterSet
+    table = tables.UserTable
+    form = forms.UserBulkEditForm
+
+
+class UserBulkImportView(generic.BulkImportView):
+    queryset = NetBoxUser.objects.all()
+    model_form = forms.UserImportForm
+
+
+class UserBulkDeleteView(generic.BulkDeleteView):
+    queryset = NetBoxUser.objects.all()
+    filterset = filtersets.UserFilterSet
+    table = tables.UserTable
+
+
+#
+# Groups
+#
+
+
+class GroupListView(generic.ObjectListView):
+    queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+    filterset = filtersets.GroupFilterSet
+    filterset_form = forms.GroupFilterForm
+    table = tables.GroupTable
+
+
+@register_model_view(NetBoxGroup)
+class GroupView(generic.ObjectView):
+    queryset = NetBoxGroup.objects.all()
+    template_name = 'users/group.html'
+
+
+@register_model_view(NetBoxGroup, 'edit')
+class GroupEditView(generic.ObjectEditView):
+    queryset = NetBoxGroup.objects.all()
+    form = forms.GroupForm
+
+
+@register_model_view(NetBoxGroup, 'delete')
+class GroupDeleteView(generic.ObjectDeleteView):
+    queryset = NetBoxGroup.objects.all()
+
+
+class GroupBulkImportView(generic.BulkImportView):
+    queryset = NetBoxGroup.objects.all()
+    model_form = forms.GroupImportForm
+
+
+class GroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+    filterset = filtersets.GroupFilterSet
+    table = tables.GroupTable
+
+#
+# ObjectPermissions
+#
+
+
+class ObjectPermissionListView(generic.ObjectListView):
+    queryset = ObjectPermission.objects.all()
+    filterset = filtersets.ObjectPermissionFilterSet
+    filterset_form = forms.ObjectPermissionFilterForm
+    table = tables.ObjectPermissionTable
+
+
+@register_model_view(ObjectPermission)
+class ObjectPermissionView(generic.ObjectView):
+    queryset = ObjectPermission.objects.all()
+    template_name = 'users/objectpermission.html'
+
+
+@register_model_view(ObjectPermission, 'edit')
+class ObjectPermissionEditView(generic.ObjectEditView):
+    queryset = ObjectPermission.objects.all()
+    form = forms.ObjectPermissionForm
+
+
+@register_model_view(ObjectPermission, 'delete')
+class ObjectPermissionDeleteView(generic.ObjectDeleteView):
+    queryset = ObjectPermission.objects.all()
+
+
+class ObjectPermissionBulkEditView(generic.BulkEditView):
+    queryset = ObjectPermission.objects.all()
+    filterset = filtersets.ObjectPermissionFilterSet
+    table = tables.ObjectPermissionTable
+    form = forms.ObjectPermissionBulkEditForm
+
+
+class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
+    queryset = ObjectPermission.objects.all()
+    filterset = filtersets.ObjectPermissionFilterSet
+    table = tables.ObjectPermissionTable

+ 4 - 5
netbox/utilities/permissions.py

@@ -18,11 +18,10 @@ def get_permission_for_model(model, action):
     :param model: A model or instance
     :param action: View, add, change, or delete (string)
     """
-    return '{}.{}_{}'.format(
-        model._meta.app_label,
-        action,
-        model._meta.model_name
-    )
+    # Resolve to the "concrete" model (for proxy models)
+    model = model._meta.concrete_model
+
+    return f'{model._meta.app_label}.{action}_{model._meta.model_name}'
 
 
 def resolve_permission(name):

+ 2 - 4
netbox/utilities/querysets.py

@@ -1,7 +1,7 @@
 from django.db.models import Prefetch, QuerySet
 
 from users.constants import CONSTRAINT_TOKEN_USER
-from utilities.permissions import permission_is_exempt, qs_filter_from_constraints
+from utilities.permissions import get_permission_for_model, permission_is_exempt, qs_filter_from_constraints
 
 __all__ = (
     'RestrictedPrefetch',
@@ -46,9 +46,7 @@ class RestrictedQuerySet(QuerySet):
         :param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view'
         """
         # Resolve the full name of the required permission
-        app_label = self.model._meta.app_label
-        model_name = self.model._meta.model_name
-        permission_required = f'{app_label}.{action}_{model_name}'
+        permission_required = get_permission_for_model(self.model, action)
 
         # Bypass restriction for superusers and exempt views
         if user.is_superuser or permission_is_exempt(permission_required):

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

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