1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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>
This commit is contained in:
Arthur Hanson
2023-07-21 03:22:08 +07:00
committed by GitHub
parent 96ea0ac9c7
commit a4acb50edd
32 changed files with 1545 additions and 421 deletions

View File

@ -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=(

View File

@ -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]

View File

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

View File

@ -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 %}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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")

View File

@ -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}'
})

View File

@ -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])

View File

@ -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']

View File

@ -0,0 +1,5 @@
from .authentication import *
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
from .model_forms import *

View File

@ -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

View File

@ -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',)

View File

@ -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)

View File

@ -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'),
)

View File

@ -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)
})

View File

@ -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'},
),
]

View File

@ -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])

View File

@ -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',
)

View File

@ -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()

View File

@ -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',
}

View File

@ -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'))),
]

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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)