@@ -19,9 +19,7 @@
Key |
-
-
-
+ {% copy_content "token_id" %}
{{ key }}
|
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/account/api_tokens.html
similarity index 94%
rename from netbox/templates/users/api_tokens.html
rename to netbox/templates/users/account/api_tokens.html
index e1641468c..25f5f02e6 100644
--- a/netbox/templates/users/api_tokens.html
+++ b/netbox/templates/users/account/api_tokens.html
@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
diff --git a/netbox/templates/users/base.html b/netbox/templates/users/account/base.html
similarity index 59%
rename from netbox/templates/users/base.html
rename to netbox/templates/users/account/base.html
index 58861ee90..f492f89ec 100644
--- a/netbox/templates/users/base.html
+++ b/netbox/templates/users/account/base.html
@@ -1,20 +1,24 @@
{% extends 'base/layout.html' %}
+{% load i18n %}
{% block tabs %}
{% endblock %}
diff --git a/netbox/templates/users/account/bookmarks.html b/netbox/templates/users/account/bookmarks.html
new file mode 100644
index 000000000..fa3c28c7c
--- /dev/null
+++ b/netbox/templates/users/account/bookmarks.html
@@ -0,0 +1,34 @@
+{% extends 'users/account/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}Bookmarks{% endblock %}
+
+{% block content %}
+
+
+{% endblock %}
diff --git a/netbox/templates/users/password.html b/netbox/templates/users/account/password.html
similarity index 94%
rename from netbox/templates/users/password.html
rename to netbox/templates/users/account/password.html
index 02e80bb26..dcdd19e29 100644
--- a/netbox/templates/users/password.html
+++ b/netbox/templates/users/account/password.html
@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
{% load form_helpers %}
{% block title %}Change Password{% endblock %}
diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/account/preferences.html
similarity index 98%
rename from netbox/templates/users/preferences.html
rename to netbox/templates/users/account/preferences.html
index f2c88db3c..59cca302c 100644
--- a/netbox/templates/users/preferences.html
+++ b/netbox/templates/users/account/preferences.html
@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
{% load helpers %}
{% load form_helpers %}
diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/account/profile.html
similarity index 98%
rename from netbox/templates/users/profile.html
rename to netbox/templates/users/account/profile.html
index 913784c94..0e8ab1162 100644
--- a/netbox/templates/users/profile.html
+++ b/netbox/templates/users/account/profile.html
@@ -1,4 +1,4 @@
-{% extends 'users/base.html' %}
+{% extends 'users/account/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html
new file mode 100644
index 000000000..e4eee0812
--- /dev/null
+++ b/netbox/templates/users/group.html
@@ -0,0 +1,48 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
+
+{% block subtitle %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% trans "Name" %} |
+ {{ object.name }} |
+
+
+
+
+
+
+
+
+
+ {% for user in object.user_set.all %}
+
{{ user }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+
+ {% for perm in object.object_permissions.all %}
+
{{ perm }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html
new file mode 100644
index 000000000..4da5a6ea5
--- /dev/null
+++ b/netbox/templates/users/objectpermission.html
@@ -0,0 +1,97 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
+
+{% block subtitle %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% trans "Name" %} |
+ {{ object.name }} |
+
+
+ {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+ {% trans "Enabled" %} |
+ {% checkmark object.enabled %} |
+
+
+
+
+
+
+
+
+
+ {% trans "View" %} |
+ {% checkmark object.can_view %} |
+
+
+ {% trans "Add" %} |
+ {% checkmark object.can_add %} |
+
+
+ {% trans "Change" %} |
+ {% checkmark object.can_change %} |
+
+
+ {% trans "Delete" %} |
+ {% checkmark object.can_delete %} |
+
+
+
+
+
+
+
+ {% if object.constraints %}
+
{{ object.constraints|json }}
+ {% else %}
+
None
+ {% endif %}
+
+
+
+
+
+
+
+ {% for user in object.object_types.all %}
+ - {{ user }}
+ {% endfor %}
+
+
+
+
+
+ {% for user in object.users.all %}
+
{{ user }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+
+ {% for group in object.groups.all %}
+
{{ group }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html
new file mode 100644
index 000000000..fe03f41ed
--- /dev/null
+++ b/netbox/templates/users/user.html
@@ -0,0 +1,84 @@
+{% extends 'generic/object.html' %}
+{% load i18n %}
+{% load helpers %}
+{% load render_table from django_tables2 %}
+
+{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
+
+{% block subtitle %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% trans "Username" %} |
+ {{ object.username }} |
+
+
+ {% trans "Full Name" %} |
+ {{ object.get_full_name|placeholder }} |
+
+
+ {% trans "Email" %} |
+ {{ object.email|placeholder }} |
+
+
+ {% trans "Account Created" %} |
+ {{ object.date_joined|annotated_date }} |
+
+
+ {% trans "Active" %} |
+ {% checkmark object.active %} |
+
+
+ {% trans "Staff" %} |
+ {% checkmark object.is_staff %} |
+
+
+ {% trans "Superuser" %} |
+ {% checkmark object.is_superuser %} |
+
+
+
+
+
+
+
+
+
+ {% for group in object.groups.all %}
+
{{ group }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+
+ {% for perm in object.object_permissions.all %}
+
{{ perm }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+ {% if perms.extras.view_objectchange %}
+
+
+
+
+
+ {% render_table changelog_table 'inc/table.html' %}
+
+
+
+
+ {% endif %}
+{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 51fd8aa80..3d3b498ad 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -46,12 +46,13 @@
Primary IPv4 |
{% if object.primary_ip4 %}
- {{ object.primary_ip4.address.ip }}
+ {{ object.primary_ip4.address.ip }}
{% if object.primary_ip4.nat_inside %}
(NAT for {{ object.primary_ip4.nat_inside.address.ip }})
{% elif object.primary_ip4.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
+ {% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -61,12 +62,13 @@
| Primary IPv6 |
{% if object.primary_ip6 %}
- {{ object.primary_ip6.address.ip }}
+ {{ object.primary_ip6.address.ip }}
{% if object.primary_ip6.nat_inside %}
(NAT for {{ object.primary_ip6.nat_inside.address.ip }})
{% elif object.primary_ip6.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
+ {% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py
index 440541b5f..1df5e3305 100644
--- a/netbox/tenancy/models/contacts.py
+++ b/netbox/tenancy/models/contacts.py
@@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk])
+
+ def to_objectchange(self, action):
+ objectchange = super().to_objectchange(action)
+ objectchange.related_object = self.object
+ return objectchange
diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py
index 0c697af79..7de8ffceb 100644
--- a/netbox/tenancy/tables/contacts.py
+++ b/netbox/tenancy/tables/contacts.py
@@ -1,4 +1,5 @@
import django_tables2 as tables
+from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.models import *
@@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
role = tables.Column(
linkify=True
)
+ contact_title = tables.Column(
+ accessor=Accessor('contact__title'),
+ verbose_name='Contact Title'
+ )
+ contact_phone = tables.Column(
+ accessor=Accessor('contact__phone'),
+ verbose_name='Contact Phone'
+ )
+ contact_email = tables.Column(
+ accessor=Accessor('contact__email'),
+ verbose_name='Contact Email'
+ )
+ contact_address = tables.Column(
+ accessor=Accessor('contact__address'),
+ verbose_name='Contact Address'
+ )
+ contact_link = tables.Column(
+ accessor=Accessor('contact__link'),
+ verbose_name='Contact Link'
+ )
+ contact_description = tables.Column(
+ accessor=Accessor('contact__description'),
+ verbose_name='Contact Description'
+ )
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(NetBoxTable.Meta):
model = ContactAssignment
- fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions')
- default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
+ fields = (
+ 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
+ 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions'
+ )
+ default_columns = (
+ 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
+ )
diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py
index 2db822cfe..316346c50 100644
--- a/netbox/users/admin/__init__.py
+++ b/netbox/users/admin/__init__.py
@@ -15,41 +15,6 @@ admin.site.unregister(Group)
admin.site.unregister(User)
-@admin.register(Group)
-class GroupAdmin(admin.ModelAdmin):
- form = forms.GroupAdminForm
- list_display = ('name', 'user_count')
- ordering = ('name',)
- search_fields = ('name',)
- inlines = [inlines.GroupObjectPermissionInline]
-
- @staticmethod
- def user_count(obj):
- return obj.user_set.count()
-
-
-@admin.register(User)
-class UserAdmin(UserAdmin_):
- list_display = [
- 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
- ]
- fieldsets = (
- (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
- ('Groups', {'fields': ('groups',)}),
- ('Status', {
- 'fields': ('is_active', 'is_staff', 'is_superuser'),
- }),
- ('Important dates', {'fields': ('last_login', 'date_joined')}),
- )
- filter_horizontal = ('groups',)
- list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
-
- def get_inlines(self, request, obj):
- if obj is not None:
- return (inlines.UserObjectPermissionInline, inlines.UserConfigInline)
- return ()
-
-
#
# REST API tokens
#
@@ -64,66 +29,3 @@ class TokenAdmin(admin.ModelAdmin):
def list_allowed_ips(self, obj):
return obj.allowed_ips or 'Any'
list_allowed_ips.short_description = "Allowed IPs"
-
-
-#
-# Permissions
-#
-
-@admin.register(ObjectPermission)
-class ObjectPermissionAdmin(admin.ModelAdmin):
- actions = ('enable', 'disable')
- fieldsets = (
- (None, {
- 'fields': ('name', 'description', 'enabled')
- }),
- ('Actions', {
- 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
- }),
- ('Objects', {
- 'fields': ('object_types',)
- }),
- ('Assignment', {
- 'fields': ('groups', 'users')
- }),
- ('Constraints', {
- 'fields': ('constraints',),
- 'classes': ('monospace',)
- }),
- )
- filter_horizontal = ('object_types', 'groups', 'users')
- form = forms.ObjectPermissionForm
- list_display = [
- 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
- ]
- list_filter = [
- 'enabled', filters.ActionListFilter, filters.ObjectTypeListFilter, 'groups', 'users'
- ]
- search_fields = ['actions', 'constraints', 'description', 'name']
-
- def get_queryset(self, request):
- return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
-
- def list_models(self, obj):
- return ', '.join([f"{ct}" for ct in obj.object_types.all()])
- list_models.short_description = 'Models'
-
- def list_users(self, obj):
- return ', '.join([u.username for u in obj.users.all()])
- list_users.short_description = 'Users'
-
- def list_groups(self, obj):
- return ', '.join([g.name for g in obj.groups.all()])
- list_groups.short_description = 'Groups'
-
- #
- # Admin actions
- #
-
- def enable(self, request, queryset):
- updated = queryset.update(enabled=True)
- self.message_user(request, f"Enabled {updated} permissions")
-
- def disable(self, request, queryset):
- updated = queryset.update(enabled=False)
- self.message_user(request, f"Disabled {updated} permissions")
diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py
index 986ddd0aa..7db6a124c 100644
--- a/netbox/users/admin/forms.py
+++ b/netbox/users/admin/forms.py
@@ -1,49 +1,13 @@
from django import forms
-from django.contrib.auth.models import Group, User
-from django.contrib.admin.widgets import FilteredSelectMultiple
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, ValidationError
from django.utils.translation import gettext as _
-from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
-from users.models import ObjectPermission, Token
-from utilities.forms.fields import ContentTypeMultipleChoiceField
-from utilities.permissions import qs_filter_from_constraints
+from users.models import Token
__all__ = (
- 'GroupAdminForm',
- 'ObjectPermissionForm',
'TokenAdminForm',
)
-class GroupAdminForm(forms.ModelForm):
- users = forms.ModelMultipleChoiceField(
- queryset=User.objects.all(),
- required=False,
- widget=FilteredSelectMultiple('users', False)
- )
-
- class Meta:
- model = Group
- fields = ('name', 'users')
-
- def __init__(self, *args, **kwargs):
- super(GroupAdminForm, self).__init__(*args, **kwargs)
-
- if self.instance.pk:
- self.fields['users'].initial = self.instance.user_set.all()
-
- def save_m2m(self):
- self.instance.user_set.set(self.cleaned_data['users'])
-
- def save(self, *args, **kwargs):
- instance = super(GroupAdminForm, self).save()
- self.save_m2m()
-
- return instance
-
-
class TokenAdminForm(forms.ModelForm):
key = forms.CharField(
required=False,
@@ -55,82 +19,3 @@ class TokenAdminForm(forms.ModelForm):
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
]
model = Token
-
-
-class ObjectPermissionForm(forms.ModelForm):
- object_types = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES
- )
- can_view = forms.BooleanField(required=False)
- can_add = forms.BooleanField(required=False)
- can_change = forms.BooleanField(required=False)
- can_delete = forms.BooleanField(required=False)
-
- class Meta:
- model = ObjectPermission
- exclude = []
- help_texts = {
- 'actions': _('Actions granted in addition to those listed above'),
- 'constraints': _('JSON expression of a queryset filter that will return only permitted objects. Leave null '
- 'to match all objects of this type. A list of multiple objects will result in a logical OR '
- 'operation.')
- }
- labels = {
- 'actions': 'Additional actions'
- }
- widgets = {
- 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'})
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Make the actions field optional since the admin form uses it only for non-CRUD actions
- self.fields['actions'].required = False
-
- # Order group and user fields
- self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
- self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
-
- # Check the appropriate checkboxes when editing an existing ObjectPermission
- if self.instance.pk:
- for action in ['view', 'add', 'change', 'delete']:
- if action in self.instance.actions:
- self.fields[f'can_{action}'].initial = True
- self.instance.actions.remove(action)
-
- def clean(self):
- super().clean()
-
- object_types = self.cleaned_data.get('object_types')
- constraints = self.cleaned_data.get('constraints')
-
- # Append any of the selected CRUD checkboxes to the actions list
- if not self.cleaned_data.get('actions'):
- self.cleaned_data['actions'] = list()
- for action in ['view', 'add', 'change', 'delete']:
- if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
- self.cleaned_data['actions'].append(action)
-
- # At least one action must be specified
- if not self.cleaned_data['actions']:
- raise ValidationError("At least one action must be selected.")
-
- # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
- # returns anything; we just want to make sure the specified constraints are valid.
- if object_types and constraints:
- # Normalize the constraints to a list of dicts
- if type(constraints) is not list:
- constraints = [constraints]
- for ct in object_types:
- model = ct.model_class()
- try:
- tokens = {
- CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
- }
- model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
- except FieldError as e:
- raise ValidationError({
- 'constraints': f'Invalid filter for {model}: {e}'
- })
diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py
index 3510184ae..5e15fa41a 100644
--- a/netbox/users/api/nested_serializers.py
+++ b/netbox/users/api/nested_serializers.py
@@ -1,4 +1,5 @@
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
@@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail')
class Meta:
- model = User
+ model = get_user_model()
fields = ['id', 'url', 'display', 'username']
@extend_schema_field(OpenApiTypes.STR)
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 1b975791f..1f4bf4ea0 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -1,5 +1,6 @@
from django.conf import settings
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
@@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer):
)
class Meta:
- model = User
+ model = get_user_model()
fields = (
'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
'date_joined', 'groups',
@@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer):
many=True
)
users = SerializedPKRelatedField(
- queryset=User.objects.all(),
+ queryset=get_user_model().objects.all(),
serializer=NestedUserSerializer,
required=False,
many=True
diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py
index 04b3ae336..4a8e1b154 100644
--- a/netbox/users/api/views.py
+++ b/netbox/users/api/views.py
@@ -1,5 +1,6 @@
from django.contrib.auth import authenticate
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.db.models import Count
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
@@ -32,7 +33,7 @@ class UsersRootView(APIRootView):
#
class UserViewSet(NetBoxModelViewSet):
- queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username')
+ queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username')
serializer_class = serializers.UserSerializer
filterset_class = filtersets.UserFilterSet
diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py
index 4ae9df89a..a4e9a9fbc 100644
--- a/netbox/users/filtersets.py
+++ b/netbox/users/filtersets.py
@@ -1,5 +1,6 @@
import django_filters
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.db.models import Q
from django.utils.translation import gettext as _
@@ -47,8 +48,8 @@ class UserFilterSet(BaseFilterSet):
)
class Meta:
- model = User
- fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
+ model = get_user_model()
+ fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser']
def search(self, queryset, name, value):
if not value.strip():
@@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet):
)
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user',
- queryset=User.objects.all(),
+ queryset=get_user_model().objects.all(),
label=_('User'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
- queryset=User.objects.all(),
+ queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@@ -114,14 +115,26 @@ 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=User.objects.all(),
+ queryset=get_user_model().objects.all(),
label=_('User'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='users__username',
- queryset=User.objects.all(),
+ queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@@ -148,3 +161,10 @@ class ObjectPermissionFilterSet(BaseFilterSet):
Q(name__icontains=value) |
Q(description__icontains=value)
)
+
+ def _check_action(self, queryset, name, value):
+ action = name.split('_')[1]
+ if value:
+ return queryset.filter(actions__contains=[action])
+ else:
+ return queryset.exclude(actions__contains=[action])
diff --git a/netbox/users/forms.py b/netbox/users/forms.py
deleted file mode 100644
index 027fa5327..000000000
--- a/netbox/users/forms.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from django import forms
-from django.conf import settings
-from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
-from django.contrib.postgres.forms import SimpleArrayField
-from django.utils.html import mark_safe
-from django.utils.translation import gettext as _
-
-from ipam.formfields import IPNetworkFormField
-from ipam.validators import prefix_validator
-from netbox.preferences import PREFERENCES
-from utilities.forms import BootstrapMixin
-from utilities.forms.widgets import DateTimePicker
-from utilities.utils import flatten_dict
-from .models import Token, UserConfig
-
-
-class LoginForm(BootstrapMixin, AuthenticationForm):
- pass
-
-
-class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
- pass
-
-
-class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
-
- def __new__(mcs, name, bases, attrs):
-
- # Emulate a declared field for each supported user preference
- preference_fields = {}
- for field_name, preference in PREFERENCES.items():
- description = f'{preference.description} ' if preference.description else ''
- help_text = f'{description}{field_name} '
- field_kwargs = {
- 'label': preference.label,
- 'choices': preference.choices,
- 'help_text': mark_safe(help_text),
- 'coerce': preference.coerce,
- 'required': False,
- 'widget': forms.Select,
- }
- preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
- attrs.update(preference_fields)
-
- return super().__new__(mcs, name, bases, attrs)
-
-
-class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
- fieldsets = (
- ('User Interface', (
- 'pagination.per_page',
- 'pagination.placement',
- 'ui.colormode',
- )),
- ('Miscellaneous', (
- 'data_format',
- )),
- )
- # List of clearable preferences
- pk = forms.MultipleChoiceField(
- choices=[],
- required=False
- )
-
- class Meta:
- model = UserConfig
- fields = ()
-
- def __init__(self, *args, instance=None, **kwargs):
-
- # Get initial data from UserConfig instance
- initial_data = flatten_dict(instance.data)
- kwargs['initial'] = initial_data
-
- super().__init__(*args, instance=instance, **kwargs)
-
- # Compile clearable preference choices
- self.fields['pk'].choices = (
- (f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
- )
-
- def save(self, *args, **kwargs):
-
- # Set UserConfig data
- for pref_name, value in self.cleaned_data.items():
- if pref_name == 'pk':
- continue
- self.instance.set(pref_name, value, commit=False)
-
- # Clear selected preferences
- for preference in self.cleaned_data['pk']:
- self.instance.clear(preference)
-
- return super().save(*args, **kwargs)
-
- @property
- def plugin_fields(self):
- return [
- name for name in self.fields.keys() if name.startswith('plugins.')
- ]
-
-
-class TokenForm(BootstrapMixin, forms.ModelForm):
- key = forms.CharField(
- required=False,
- help_text=_("If no key is provided, one will be generated automatically.")
- )
- allowed_ips = SimpleArrayField(
- base_field=IPNetworkFormField(validators=[prefix_validator]),
- required=False,
- label=_('Allowed IPs'),
- help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
- 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64 '),
- )
-
- class Meta:
- model = Token
- fields = [
- 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
- ]
- widgets = {
- 'expires': DateTimePicker(),
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Omit the key field if token retrieval is not permitted
- if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
- del self.fields['key']
diff --git a/netbox/users/forms/__init__.py b/netbox/users/forms/__init__.py
new file mode 100644
index 000000000..a545c3add
--- /dev/null
+++ b/netbox/users/forms/__init__.py
@@ -0,0 +1,5 @@
+from .authentication import *
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .model_forms import *
diff --git a/netbox/users/forms/authentication.py b/netbox/users/forms/authentication.py
new file mode 100644
index 000000000..2b540b752
--- /dev/null
+++ b/netbox/users/forms/authentication.py
@@ -0,0 +1,25 @@
+from django.contrib.auth.forms import (
+ AuthenticationForm,
+ PasswordChangeForm as DjangoPasswordChangeForm,
+)
+
+from utilities.forms import BootstrapMixin
+
+__all__ = (
+ 'LoginForm',
+ 'PasswordChangeForm',
+)
+
+
+class LoginForm(BootstrapMixin, AuthenticationForm):
+ """
+ Used to authenticate a user by username and password.
+ """
+ pass
+
+
+class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
+ """
+ This form enables a user to change his or her own password.
+ """
+ pass
diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py
new file mode 100644
index 000000000..db40283ba
--- /dev/null
+++ b/netbox/users/forms/bulk_edit.py
@@ -0,0 +1,72 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from users.models import *
+from utilities.forms import BootstrapMixin
+from utilities.forms.widgets import BulkEditNullBooleanSelect
+
+__all__ = (
+ 'ObjectPermissionBulkEditForm',
+ 'UserBulkEditForm',
+)
+
+
+class UserBulkEditForm(BootstrapMixin, forms.Form):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=NetBoxUser.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ first_name = forms.CharField(
+ label=_('First name'),
+ max_length=150,
+ required=False
+ )
+ last_name = forms.CharField(
+ label=_('Last name'),
+ max_length=150,
+ required=False
+ )
+ is_active = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect,
+ label=_('Active')
+ )
+ is_staff = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect,
+ label=_('Staff status')
+ )
+ is_superuser = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect,
+ label=_('Superuser status')
+ )
+
+ model = NetBoxUser
+ fieldsets = (
+ (None, ('first_name', 'last_name', 'is_active', 'is_staff', 'is_superuser')),
+ )
+ nullable_fields = ('first_name', 'last_name')
+
+
+class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ObjectPermission.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ enabled = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect,
+ label=_('Enabled')
+ )
+
+ model = ObjectPermission
+ fieldsets = (
+ (None, ('enabled', 'description')),
+ )
+ nullable_fields = ('description',)
diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py
new file mode 100644
index 000000000..25f779044
--- /dev/null
+++ b/netbox/users/forms/bulk_import.py
@@ -0,0 +1,32 @@
+from users.models import NetBoxGroup, NetBoxUser
+from utilities.forms import CSVModelForm
+
+__all__ = (
+ 'GroupImportForm',
+ 'UserImportForm',
+)
+
+
+class GroupImportForm(CSVModelForm):
+
+ class Meta:
+ model = NetBoxGroup
+ fields = (
+ 'name',
+ )
+
+
+class UserImportForm(CSVModelForm):
+
+ class Meta:
+ model = NetBoxUser
+ fields = (
+ 'username', 'first_name', 'last_name', 'email', 'password', 'is_staff',
+ 'is_active', 'is_superuser'
+ )
+
+ def save(self, *args, **kwargs):
+ # Set the hashed password
+ self.instance.set_password(self.cleaned_data.get('password'))
+
+ return super().save(*args, **kwargs)
diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py
new file mode 100644
index 000000000..eca76dea4
--- /dev/null
+++ b/netbox/users/forms/filtersets.py
@@ -0,0 +1,111 @@
+from django import forms
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.utils.translation import gettext_lazy as _
+
+from netbox.forms import NetBoxModelFilterSetForm
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
+from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
+from utilities.forms.fields import DynamicModelMultipleChoiceField
+
+__all__ = (
+ 'GroupFilterForm',
+ 'ObjectPermissionFilterForm',
+ 'UserFilterForm',
+)
+
+
+class GroupFilterForm(NetBoxModelFilterSetForm):
+ model = NetBoxGroup
+ fieldsets = (
+ (None, ('q', 'filter_id',)),
+ )
+
+
+class UserFilterForm(NetBoxModelFilterSetForm):
+ model = NetBoxUser
+ fieldsets = (
+ (None, ('q', 'filter_id',)),
+ (_('Group'), ('group_id',)),
+ (_('Status'), ('is_active', 'is_staff', 'is_superuser')),
+ )
+ group_id = DynamicModelMultipleChoiceField(
+ queryset=Group.objects.all(),
+ required=False,
+ label=_('Group')
+ )
+ is_active = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Is Active'),
+ )
+ is_staff = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Is Staff'),
+ )
+ is_superuser = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Is Superuser'),
+ )
+
+
+class ObjectPermissionFilterForm(NetBoxModelFilterSetForm):
+ model = ObjectPermission
+ fieldsets = (
+ (None, ('q', 'filter_id',)),
+ (_('Permission'), ('enabled', 'group_id', 'user_id')),
+ (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete')),
+ )
+ enabled = forms.NullBooleanField(
+ label=_('Enabled'),
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ group_id = DynamicModelMultipleChoiceField(
+ queryset=Group.objects.all(),
+ required=False,
+ label=_('Group')
+ )
+ user_id = DynamicModelMultipleChoiceField(
+ queryset=get_user_model().objects.all(),
+ required=False,
+ label=_('User')
+ )
+ can_view = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Can View'),
+ )
+ can_add = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Can Add'),
+ )
+ can_change = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Can Change'),
+ )
+ can_delete = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ ),
+ label=_('Can Delete'),
+ )
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
new file mode 100644
index 000000000..43b95893a
--- /dev/null
+++ b/netbox/users/forms/model_forms.py
@@ -0,0 +1,381 @@
+from django import forms
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.forms import SimpleArrayField
+from django.core.exceptions import FieldError
+from django.utils.html import mark_safe
+from django.utils.translation import gettext_lazy as _
+
+from ipam.formfields import IPNetworkFormField
+from ipam.validators import prefix_validator
+from netbox.preferences import PREFERENCES
+from users.constants import *
+from users.models import *
+from utilities.forms import BootstrapMixin
+from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import DateTimePicker
+from utilities.permissions import qs_filter_from_constraints
+from utilities.utils import flatten_dict
+
+__all__ = (
+ 'GroupForm',
+ 'ObjectPermissionForm',
+ 'TokenForm',
+ 'UserConfigForm',
+ 'UserForm',
+)
+
+
+class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
+
+ def __new__(mcs, name, bases, attrs):
+
+ # Emulate a declared field for each supported user preference
+ preference_fields = {}
+ for field_name, preference in PREFERENCES.items():
+ description = f'{preference.description} ' if preference.description else ''
+ help_text = f'{description}{field_name} '
+ field_kwargs = {
+ 'label': preference.label,
+ 'choices': preference.choices,
+ 'help_text': mark_safe(help_text),
+ 'coerce': preference.coerce,
+ 'required': False,
+ 'widget': forms.Select,
+ }
+ preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
+ attrs.update(preference_fields)
+
+ return super().__new__(mcs, name, bases, attrs)
+
+
+class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
+ fieldsets = (
+ (_('User Interface'), (
+ 'pagination.per_page',
+ 'pagination.placement',
+ 'ui.colormode',
+ )),
+ (_('Miscellaneous'), (
+ 'data_format',
+ )),
+ )
+ # List of clearable preferences
+ pk = forms.MultipleChoiceField(
+ label=_('Pk'),
+ choices=[],
+ required=False
+ )
+
+ class Meta:
+ model = UserConfig
+ fields = ()
+
+ def __init__(self, *args, instance=None, **kwargs):
+
+ # Get initial data from UserConfig instance
+ initial_data = flatten_dict(instance.data)
+ kwargs['initial'] = initial_data
+
+ super().__init__(*args, instance=instance, **kwargs)
+
+ # Compile clearable preference choices
+ self.fields['pk'].choices = (
+ (f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
+ )
+
+ def save(self, *args, **kwargs):
+
+ # Set UserConfig data
+ for pref_name, value in self.cleaned_data.items():
+ if pref_name == 'pk':
+ continue
+ self.instance.set(pref_name, value, commit=False)
+
+ # Clear selected preferences
+ for preference in self.cleaned_data['pk']:
+ self.instance.clear(preference)
+
+ return super().save(*args, **kwargs)
+
+ @property
+ def plugin_fields(self):
+ return [
+ name for name in self.fields.keys() if name.startswith('plugins.')
+ ]
+
+
+class TokenForm(BootstrapMixin, forms.ModelForm):
+ key = forms.CharField(
+ label=_('Key'),
+ required=False,
+ help_text=_("If no key is provided, one will be generated automatically.")
+ )
+ allowed_ips = SimpleArrayField(
+ base_field=IPNetworkFormField(validators=[prefix_validator]),
+ required=False,
+ label=_('Allowed IPs'),
+ help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+ 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64 '),
+ )
+
+ class Meta:
+ model = Token
+ fields = [
+ 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
+ ]
+ widgets = {
+ 'expires': DateTimePicker(),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Omit the key field if token retrieval is not permitted
+ if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
+ del self.fields['key']
+
+
+class UserForm(BootstrapMixin, forms.ModelForm):
+ password = forms.CharField(
+ label=_('Password'),
+ widget=forms.PasswordInput(),
+ required=True,
+ )
+ confirm_password = forms.CharField(
+ label=_('Confirm password'),
+ widget=forms.PasswordInput(),
+ required=True,
+ help_text=_("Enter the same password as before, for verification."),
+ )
+ groups = DynamicModelMultipleChoiceField(
+ label=_('Groups'),
+ required=False,
+ queryset=Group.objects.all()
+ )
+ object_permissions = DynamicModelMultipleChoiceField(
+ required=False,
+ label=_('Permissions'),
+ queryset=ObjectPermission.objects.all(),
+ to_field_name='pk',
+ )
+
+ fieldsets = (
+ (_('User'), ('username', 'password', 'confirm_password', 'first_name', 'last_name', 'email')),
+ (_('Groups'), ('groups', )),
+ (_('Status'), ('is_active', 'is_staff', 'is_superuser')),
+ (_('Permissions'), ('object_permissions',)),
+ )
+
+ class Meta:
+ model = NetBoxUser
+ fields = [
+ 'username', 'first_name', 'last_name', 'email', 'groups', 'object_permissions',
+ 'is_active', 'is_staff', 'is_superuser',
+ ]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if self.instance.pk:
+ # Populate assigned permissions
+ self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
+
+ # Password fields are optional for existing Users
+ self.fields['password'].required = False
+ self.fields['password'].widget.attrs.pop('required')
+ self.fields['confirm_password'].required = False
+ self.fields['confirm_password'].widget.attrs.pop('required')
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+
+ # Update assigned permissions
+ instance.object_permissions.set(self.cleaned_data['object_permissions'])
+
+ # On edit, check if we have to save the password
+ if self.cleaned_data.get('password'):
+ instance.set_password(self.cleaned_data.get('password'))
+ instance.save()
+
+ return instance
+
+ def clean(self):
+
+ # Check that password confirmation matches if password is set
+ if self.cleaned_data['password'] and self.cleaned_data['password'] != self.cleaned_data['confirm_password']:
+ raise forms.ValidationError(_("Passwords do not match! Please check your input and try again."))
+
+ # TODO: Move this logic to the NetBoxUser class
+ def clean_username(self):
+ """Reject usernames that differ only in case."""
+ instance = getattr(self, 'instance', None)
+ if instance:
+ qs = self._meta.model.objects.exclude(pk=instance.pk)
+ else:
+ qs = self._meta.model.objects.all()
+
+ username = self.cleaned_data.get("username")
+ if (
+ username and qs.filter(username__iexact=username).exists()
+ ):
+ raise forms.ValidationError(
+ _("user with this username already exists")
+ )
+
+ return username
+
+
+class GroupForm(BootstrapMixin, forms.ModelForm):
+ users = DynamicModelMultipleChoiceField(
+ label=_('Users'),
+ required=False,
+ queryset=get_user_model().objects.all()
+ )
+ object_permissions = DynamicModelMultipleChoiceField(
+ required=False,
+ label=_('Permissions'),
+ queryset=ObjectPermission.objects.all(),
+ to_field_name='pk',
+ )
+
+ fieldsets = (
+ (None, ('name', )),
+ (_('Users'), ('users', )),
+ (_('Permissions'), ('object_permissions', )),
+ )
+
+ class Meta:
+ model = NetBoxGroup
+ fields = [
+ 'name', 'users', 'object_permissions',
+ ]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Populate assigned users and permissions
+ if self.instance.pk:
+ self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True)
+ self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+
+ # Update assigned users and permissions
+ instance.user_set.set(self.cleaned_data['users'])
+ instance.object_permissions.set(self.cleaned_data['object_permissions'])
+
+ return instance
+
+
+class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
+ object_types = ContentTypeMultipleChoiceField(
+ label=_('Object types'),
+ queryset=ContentType.objects.all(),
+ limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
+ widget=forms.SelectMultiple(attrs={'size': 6})
+ )
+ can_view = forms.BooleanField(
+ required=False
+ )
+ can_add = forms.BooleanField(
+ required=False
+ )
+ can_change = forms.BooleanField(
+ required=False
+ )
+ can_delete = forms.BooleanField(
+ required=False
+ )
+ actions = SimpleArrayField(
+ label=_('Additional actions'),
+ base_field=forms.CharField(),
+ required=False,
+ help_text=_('Actions granted in addition to those listed above')
+ )
+ users = DynamicModelMultipleChoiceField(
+ label=_('Users'),
+ required=False,
+ queryset=get_user_model().objects.all()
+ )
+ groups = DynamicModelMultipleChoiceField(
+ label=_('Groups'),
+ required=False,
+ queryset=Group.objects.all()
+ )
+
+ fieldsets = (
+ (None, ('name', 'description', 'enabled',)),
+ (_('Actions'), ('can_view', 'can_add', 'can_change', 'can_delete', 'actions')),
+ (_('Objects'), ('object_types', )),
+ (_('Assignment'), ('groups', 'users')),
+ (_('Constraints'), ('constraints',))
+ )
+
+ class Meta:
+ model = ObjectPermission
+ fields = [
+ 'name', 'description', 'enabled', 'object_types', 'users', 'groups', 'constraints', 'actions',
+ ]
+ help_texts = {
+ 'constraints': _(
+ 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
+ 'to match all objects of this type. A list of multiple objects will result in a logical OR '
+ 'operation.'
+ )
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Make the actions field optional since the form uses it only for non-CRUD actions
+ self.fields['actions'].required = False
+
+ # Order group and user fields
+ self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
+ self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
+
+ # Check the appropriate checkboxes when editing an existing ObjectPermission
+ if self.instance.pk:
+ for action in ['view', 'add', 'change', 'delete']:
+ if action in self.instance.actions:
+ self.fields[f'can_{action}'].initial = True
+ self.instance.actions.remove(action)
+
+ def clean(self):
+ super().clean()
+
+ object_types = self.cleaned_data.get('object_types')
+ constraints = self.cleaned_data.get('constraints')
+
+ # Append any of the selected CRUD checkboxes to the actions list
+ if not self.cleaned_data.get('actions'):
+ self.cleaned_data['actions'] = list()
+ for action in ['view', 'add', 'change', 'delete']:
+ if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
+ self.cleaned_data['actions'].append(action)
+
+ # At least one action must be specified
+ if not self.cleaned_data['actions']:
+ raise forms.ValidationError(_("At least one action must be selected."))
+
+ # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
+ # returns anything; we just want to make sure the specified constraints are valid.
+ if object_types and constraints:
+ # Normalize the constraints to a list of dicts
+ if type(constraints) is not list:
+ constraints = [constraints]
+ for ct in object_types:
+ model = ct.model_class()
+ try:
+ tokens = {
+ CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
+ }
+ model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
+ except FieldError as e:
+ raise forms.ValidationError({
+ 'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e)
+ })
diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py
index 3b04d8418..f033a535a 100644
--- a/netbox/users/graphql/schema.py
+++ b/netbox/users/graphql/schema.py
@@ -1,6 +1,7 @@
import graphene
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
from utilities.graphql_optimizer import gql_query_optimizer
@@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType):
user_list = ObjectListField(UserType)
def resolve_user_list(root, info, **kwargs):
- return gql_query_optimizer(User.objects.all(), info)
+ return gql_query_optimizer(get_user_model().objects.all(), info)
diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py
index d948686c6..4254f1791 100644
--- a/netbox/users/graphql/types.py
+++ b/netbox/users/graphql/types.py
@@ -1,4 +1,5 @@
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from graphene_django import DjangoObjectType
from users import filtersets
@@ -25,7 +26,7 @@ class GroupType(DjangoObjectType):
class UserType(DjangoObjectType):
class Meta:
- model = User
+ model = get_user_model()
fields = (
'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
'groups',
@@ -34,4 +35,4 @@ class UserType(DjangoObjectType):
@classmethod
def get_queryset(cls, queryset, info):
- return RestrictedQuerySet(model=User).restrict(info.context.user, 'view')
+ return RestrictedQuerySet(model=get_user_model()).restrict(info.context.user, 'view')
diff --git a/netbox/users/migrations/0004_netboxgroup_netboxuser.py b/netbox/users/migrations/0004_netboxgroup_netboxuser.py
new file mode 100644
index 000000000..59d941643
--- /dev/null
+++ b/netbox/users/migrations/0004_netboxgroup_netboxuser.py
@@ -0,0 +1,50 @@
+# Generated by Django 4.1.9 on 2023-06-06 18:15
+
+import django.contrib.auth.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('users', '0003_token_allowed_ips_last_used'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='NetBoxGroup',
+ fields=[],
+ options={
+ 'verbose_name': 'Group',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('auth.group',),
+ managers=[
+ ('objects', django.contrib.auth.models.GroupManager()),
+ ],
+ ),
+ migrations.CreateModel(
+ name='NetBoxUser',
+ fields=[],
+ options={
+ 'verbose_name': 'User',
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('auth.user',),
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ migrations.AlterModelOptions(
+ name='netboxgroup',
+ options={'ordering': ('name',), 'verbose_name': 'Group'},
+ ),
+ migrations.AlterModelOptions(
+ name='netboxuser',
+ options={'ordering': ('username',), 'verbose_name': 'User'},
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 4e7d9ca52..a8060dd63 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -2,13 +2,14 @@ import binascii
import os
from django.conf import settings
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group, GroupManager, User, UserManager
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
+from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from netaddr import IPNetwork
@@ -20,6 +21,8 @@ from utilities.utils import flatten_dict
from .constants import *
__all__ = (
+ 'NetBoxGroup',
+ 'NetBoxUser',
'ObjectPermission',
'Token',
'UserConfig',
@@ -30,6 +33,7 @@ __all__ = (
# Proxy models for admin
#
+
class AdminGroup(Group):
"""
Proxy contrib.auth.models.Group for the admin UI
@@ -48,6 +52,44 @@ class AdminUser(User):
proxy = True
+class NetBoxUserManager(UserManager.from_queryset(RestrictedQuerySet)):
+ pass
+
+
+class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
+ pass
+
+
+class NetBoxUser(User):
+ """
+ Proxy contrib.auth.models.User for the UI
+ """
+ objects = NetBoxUserManager()
+
+ class Meta:
+ verbose_name = 'User'
+ proxy = True
+ ordering = ('username',)
+
+ def get_absolute_url(self):
+ return reverse('users:netboxuser', args=[self.pk])
+
+
+class NetBoxGroup(Group):
+ """
+ Proxy contrib.auth.models.User for the UI
+ """
+ objects = NetBoxGroupManager()
+
+ class Meta:
+ verbose_name = 'Group'
+ proxy = True
+ ordering = ('name',)
+
+ def get_absolute_url(self):
+ return reverse('users:netboxgroup', args=[self.pk])
+
+
#
# User preferences
#
@@ -325,6 +367,22 @@ class ObjectPermission(models.Model):
def __str__(self):
return self.name
+ @property
+ def can_view(self):
+ return 'view' in self.actions
+
+ @property
+ def can_add(self):
+ return 'add' in self.actions
+
+ @property
+ def can_change(self):
+ return 'change' in self.actions
+
+ @property
+ def can_delete(self):
+ return 'delete' in self.actions
+
def list_constraints(self):
"""
Return all constraint sets as a list (even if only a single set is defined).
@@ -332,3 +390,6 @@ class ObjectPermission(models.Model):
if type(self.constraints) is not list:
return [self.constraints]
return self.constraints
+
+ def get_absolute_url(self):
+ return reverse('users:objectpermission', args=[self.pk])
diff --git a/netbox/users/tables.py b/netbox/users/tables.py
index 0f1484887..741a4b024 100644
--- a/netbox/users/tables.py
+++ b/netbox/users/tables.py
@@ -1,8 +1,14 @@
-from .models import Token
+import django_tables2 as tables
+
from netbox.tables import NetBoxTable, columns
+from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
+from .models import Token
__all__ = (
+ 'GroupTable',
+ 'ObjectPermissionTable',
'TokenTable',
+ 'UserTable',
)
@@ -12,9 +18,7 @@ ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
-
-
-
+ {% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""
@@ -50,3 +54,72 @@ class TokenTable(NetBoxTable):
fields = (
'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)
+
+
+class UserTable(NetBoxTable):
+ username = tables.Column(
+ linkify=True
+ )
+ groups = columns.ManyToManyColumn(
+ linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+ )
+ is_active = columns.BooleanColumn()
+ is_staff = columns.BooleanColumn()
+ is_superuser = columns.BooleanColumn()
+ actions = columns.ActionsColumn(
+ actions=('edit', 'delete'),
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = NetBoxUser
+ fields = (
+ 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
+ 'is_superuser',
+ )
+ default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')
+
+
+class GroupTable(NetBoxTable):
+ name = tables.Column(linkify=True)
+ actions = columns.ActionsColumn(
+ actions=('edit', 'delete'),
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = NetBoxGroup
+ fields = (
+ 'pk', 'id', 'name', 'users_count',
+ )
+ default_columns = ('pk', 'name', 'users_count', )
+
+
+class ObjectPermissionTable(NetBoxTable):
+ name = tables.Column(linkify=True)
+ object_types = columns.ContentTypesColumn()
+ enabled = columns.BooleanColumn()
+ can_view = columns.BooleanColumn()
+ can_add = columns.BooleanColumn()
+ can_change = columns.BooleanColumn()
+ can_delete = columns.BooleanColumn()
+ custom_actions = columns.ArrayColumn(
+ accessor=tables.A('actions')
+ )
+ users = columns.ManyToManyColumn(
+ linkify_item=('users:netboxuser', {'pk': tables.A('pk')})
+ )
+ groups = columns.ManyToManyColumn(
+ linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
+ )
+ actions = columns.ActionsColumn(
+ actions=('edit', 'delete'),
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = ObjectPermission
+ fields = (
+ 'pk', 'id', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete',
+ 'custom_actions', 'users', 'groups', 'constraints', 'description',
+ )
+ default_columns = (
+ 'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description',
+ )
diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py
index 281f656d2..2de243775 100644
--- a/netbox/users/tests/test_api.py
+++ b/netbox/users/tests/test_api.py
@@ -1,4 +1,5 @@
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase
from utilities.utils import deepmerge
+User = get_user_model()
+
+
class AppTest(APITestCase):
def test_root(self):
diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py
index 33ed7e7ba..542b40b83 100644
--- a/netbox/users/tests/test_filtersets.py
+++ b/netbox/users/tests/test_filtersets.py
@@ -1,6 +1,7 @@
import datetime
-from django.contrib.auth.models import Group, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.utils.timezone import make_aware
@@ -9,6 +10,8 @@ from users import filtersets
from users.models import ObjectPermission, Token
from utilities.testing import BaseFilterSetTests
+User = get_user_model()
+
class UserTestCase(TestCase, BaseFilterSetTests):
queryset = User.objects.all()
@@ -30,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',
@@ -79,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]
@@ -187,6 +195,22 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_can_view(self):
+ params = {'can_view': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_can_add(self):
+ params = {'can_add': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_can_change(self):
+ params = {'can_change': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_can_delete(self):
+ params = {'can_delete': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
class TokenTestCase(TestCase, BaseFilterSetTests):
queryset = Token.objects.all()
diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py
index 7a2337f33..791ea8fb4 100644
--- a/netbox/users/tests/test_models.py
+++ b/netbox/users/tests/test_models.py
@@ -1,7 +1,10 @@
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from django.test import TestCase
+User = get_user_model()
+
+
class UserConfigTest(TestCase):
@classmethod
diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py
index f1e947d67..203a67bdd 100644
--- a/netbox/users/tests/test_preferences.py
+++ b/netbox/users/tests/test_preferences.py
@@ -1,4 +1,4 @@
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from django.test import override_settings
from django.test.client import RequestFactory
from django.urls import reverse
@@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = {
}
+User = get_user_model()
+
+
class UserPreferencesTest(TestCase):
user_permissions = ['dcim.view_site']
diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py
new file mode 100644
index 000000000..ca62f474e
--- /dev/null
+++ b/netbox/users/tests/test_views.py
@@ -0,0 +1,151 @@
+from django.contrib.auth.models import Group
+from django.contrib.contenttypes.models import ContentType
+
+from users.models import *
+from utilities.testing import ViewTestCases
+
+
+class UserTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+ model = NetBoxUser
+ maxDiff = None
+ validation_excluded_fields = ['password']
+
+ def _get_queryset(self):
+ # Omit the user attached to the test client
+ return self.model.objects.exclude(username='testuser')
+
+ @classmethod
+ def setUpTestData(cls):
+
+ users = (
+ NetBoxUser(username='username1', first_name='first1', last_name='last1', email='user1@foo.com', password='pass1xxx'),
+ NetBoxUser(username='username2', first_name='first2', last_name='last2', email='user2@foo.com', password='pass2xxx'),
+ NetBoxUser(username='username3', first_name='first3', last_name='last3', email='user3@foo.com', password='pass3xxx'),
+ )
+ NetBoxUser.objects.bulk_create(users)
+
+ cls.form_data = {
+ 'username': 'usernamex',
+ 'first_name': 'firstx',
+ 'last_name': 'lastx',
+ 'email': 'userx@foo.com',
+ 'password': 'pass1xxx',
+ 'confirm_password': 'pass1xxx',
+ }
+
+ cls.csv_data = (
+ "username,first_name,last_name,email,password",
+ "username4,first4,last4,email4@foo.com,pass4xxx",
+ "username5,first5,last5,email5@foo.com,pass5xxx",
+ "username6,first6,last6,email6@foo.com,pass6xxx",
+ )
+
+ cls.csv_update_data = (
+ "id,first_name,last_name",
+ f"{users[0].pk},first7,last7",
+ f"{users[1].pk},first8,last8",
+ f"{users[2].pk},first9,last9",
+ )
+
+ cls.bulk_edit_data = {
+ 'last_name': 'newlastname',
+ }
+
+
+class GroupTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+ model = NetBoxGroup
+ maxDiff = None
+
+ @classmethod
+ def setUpTestData(cls):
+
+ groups = (
+ Group(name='group1'),
+ Group(name='group2'),
+ Group(name='group3'),
+ )
+ Group.objects.bulk_create(groups)
+
+ cls.form_data = {
+ 'name': 'groupx',
+ }
+
+ cls.csv_data = (
+ "name",
+ "group4"
+ "group5"
+ "group6"
+ )
+
+ cls.csv_update_data = (
+ "id,name",
+ f"{groups[0].pk},group7",
+ f"{groups[1].pk},group8",
+ f"{groups[2].pk},group9",
+ )
+
+
+class ObjectPermissionTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+ model = ObjectPermission
+ maxDiff = None
+
+ @classmethod
+ def setUpTestData(cls):
+ ct = ContentType.objects.get_by_natural_key('dcim', 'site')
+
+ permissions = (
+ ObjectPermission(name='Permission 1', actions=['view', 'add', 'delete']),
+ ObjectPermission(name='Permission 2', actions=['view', 'add', 'delete']),
+ ObjectPermission(name='Permission 3', actions=['view', 'add', 'delete']),
+ )
+ ObjectPermission.objects.bulk_create(permissions)
+
+ cls.form_data = {
+ 'name': 'Permission X',
+ 'description': 'A new permission',
+ 'object_types': [ct.pk],
+ 'actions': 'view,edit,delete',
+ }
+
+ cls.csv_data = (
+ "name",
+ "permission4"
+ "permission5"
+ "permission6"
+ )
+
+ cls.csv_update_data = (
+ "id,name,actions",
+ f"{permissions[0].pk},permission7",
+ f"{permissions[1].pk},permission8",
+ f"{permissions[2].pk},permission9",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
diff --git a/netbox/users/urls.py b/netbox/users/urls.py
index ed1c21c02..ca331d144 100644
--- a/netbox/users/urls.py
+++ b/netbox/users/urls.py
@@ -6,14 +6,35 @@ from . import views
app_name = 'users'
urlpatterns = [
- # User
+ # Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
+ path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
-
- # API tokens
path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
path('api-tokens//', include(get_model_urls('users', 'token'))),
+ # Users
+ path('users/', views.UserListView.as_view(), name='netboxuser_list'),
+ path('users/add/', views.UserEditView.as_view(), name='netboxuser_add'),
+ path('users/edit/', views.UserBulkEditView.as_view(), name='netboxuser_bulk_edit'),
+ path('users/import/', views.UserBulkImportView.as_view(), name='netboxuser_import'),
+ path('users/delete/', views.UserBulkDeleteView.as_view(), name='netboxuser_bulk_delete'),
+ path('users//', include(get_model_urls('users', 'netboxuser'))),
+
+ # Groups
+ path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'),
+ path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'),
+ path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'),
+ path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'),
+ path('groups//', include(get_model_urls('users', 'netboxgroup'))),
+
+ # Permissions
+ path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),
+ path('permissions/add/', views.ObjectPermissionEditView.as_view(), name='objectpermission_add'),
+ path('permissions/edit/', views.ObjectPermissionBulkEditView.as_view(), name='objectpermission_bulk_edit'),
+ path('permissions/delete/', views.ObjectPermissionBulkDeleteView.as_view(), name='objectpermission_bulk_delete'),
+ path('permissions//', include(get_model_urls('users', 'objectpermission'))),
+
]
diff --git a/netbox/users/views.py b/netbox/users/views.py
index a82620914..99635b514 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -6,6 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
+from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.urls import reverse
@@ -15,15 +16,15 @@ from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
-from extras.models import ObjectChange
-from extras.tables import ObjectChangeTable
+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 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
#
@@ -69,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')
@@ -82,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")
@@ -154,12 +155,14 @@ class LogoutView(View):
#
class ProfileView(LoginRequiredMixin, View):
- template_name = 'users/profile.html'
+ template_name = 'users/account/profile.html'
def get(self, request):
# Compile changelog table
- changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related(
+ changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
+ user=request.user
+ ).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)
@@ -171,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,
@@ -184,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()
@@ -199,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
@@ -207,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,
@@ -215,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)
@@ -228,6 +231,23 @@ class ChangePasswordView(LoginRequiredMixin, View):
})
+#
+# Bookmarks
+#
+
+class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
+ table = BookmarkTable
+ template_name = 'users/account/bookmarks.html'
+
+ def get_queryset(self, request):
+ return Bookmark.objects.filter(user=request.user)
+
+ def get_extra_context(self, request):
+ return {
+ 'active_tab': 'bookmarks',
+ }
+
+
#
# API tokens
#
@@ -237,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,
@@ -257,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,
@@ -269,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():
@@ -284,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'),
@@ -333,3 +353,138 @@ class TokenDeleteView(LoginRequiredMixin, View):
'form': form,
'return_url': reverse('users:token_list'),
})
+
+#
+# Users
+#
+
+
+class UserListView(generic.ObjectListView):
+ queryset = NetBoxUser.objects.all()
+ filterset = filtersets.UserFilterSet
+ filterset_form = forms.UserFilterForm
+ table = tables.UserTable
+
+
+@register_model_view(NetBoxUser)
+class UserView(generic.ObjectView):
+ queryset = NetBoxUser.objects.all()
+ template_name = 'users/user.html'
+
+ def get_extra_context(self, request, instance):
+ changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20]
+ changelog_table = ObjectChangeTable(changelog)
+
+ return {
+ 'changelog_table': changelog_table,
+ }
+
+
+@register_model_view(NetBoxUser, 'edit')
+class UserEditView(generic.ObjectEditView):
+ queryset = NetBoxUser.objects.all()
+ form = forms.UserForm
+
+
+@register_model_view(NetBoxUser, 'delete')
+class UserDeleteView(generic.ObjectDeleteView):
+ queryset = NetBoxUser.objects.all()
+
+
+class UserBulkEditView(generic.BulkEditView):
+ queryset = NetBoxUser.objects.all()
+ filterset = filtersets.UserFilterSet
+ table = tables.UserTable
+ form = forms.UserBulkEditForm
+
+
+class UserBulkImportView(generic.BulkImportView):
+ queryset = NetBoxUser.objects.all()
+ model_form = forms.UserImportForm
+
+
+class UserBulkDeleteView(generic.BulkDeleteView):
+ queryset = NetBoxUser.objects.all()
+ filterset = filtersets.UserFilterSet
+ table = tables.UserTable
+
+
+#
+# Groups
+#
+
+
+class GroupListView(generic.ObjectListView):
+ queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+ filterset = filtersets.GroupFilterSet
+ filterset_form = forms.GroupFilterForm
+ table = tables.GroupTable
+
+
+@register_model_view(NetBoxGroup)
+class GroupView(generic.ObjectView):
+ queryset = NetBoxGroup.objects.all()
+ template_name = 'users/group.html'
+
+
+@register_model_view(NetBoxGroup, 'edit')
+class GroupEditView(generic.ObjectEditView):
+ queryset = NetBoxGroup.objects.all()
+ form = forms.GroupForm
+
+
+@register_model_view(NetBoxGroup, 'delete')
+class GroupDeleteView(generic.ObjectDeleteView):
+ queryset = NetBoxGroup.objects.all()
+
+
+class GroupBulkImportView(generic.BulkImportView):
+ queryset = NetBoxGroup.objects.all()
+ model_form = forms.GroupImportForm
+
+
+class GroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
+ filterset = filtersets.GroupFilterSet
+ table = tables.GroupTable
+
+#
+# ObjectPermissions
+#
+
+
+class ObjectPermissionListView(generic.ObjectListView):
+ queryset = ObjectPermission.objects.all()
+ filterset = filtersets.ObjectPermissionFilterSet
+ filterset_form = forms.ObjectPermissionFilterForm
+ table = tables.ObjectPermissionTable
+
+
+@register_model_view(ObjectPermission)
+class ObjectPermissionView(generic.ObjectView):
+ queryset = ObjectPermission.objects.all()
+ template_name = 'users/objectpermission.html'
+
+
+@register_model_view(ObjectPermission, 'edit')
+class ObjectPermissionEditView(generic.ObjectEditView):
+ queryset = ObjectPermission.objects.all()
+ form = forms.ObjectPermissionForm
+
+
+@register_model_view(ObjectPermission, 'delete')
+class ObjectPermissionDeleteView(generic.ObjectDeleteView):
+ queryset = ObjectPermission.objects.all()
+
+
+class ObjectPermissionBulkEditView(generic.BulkEditView):
+ queryset = ObjectPermission.objects.all()
+ filterset = filtersets.ObjectPermissionFilterSet
+ table = tables.ObjectPermissionTable
+ form = forms.ObjectPermissionBulkEditForm
+
+
+class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
+ queryset = ObjectPermission.objects.all()
+ filterset = filtersets.ObjectPermissionFilterSet
+ table = tables.ObjectPermissionTable
diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py
index cb8c14d6d..c1e1e481c 100644
--- a/netbox/utilities/forms/fields/fields.py
+++ b/netbox/utilities/forms/fields/fields.py
@@ -11,13 +11,11 @@ from utilities.forms import widgets
from utilities.validators import EnhancedURLValidator
__all__ = (
- 'ChoiceField',
'ColorField',
'CommentField',
'JSONField',
'LaxURLField',
'MACAddressField',
- 'MultipleChoiceField',
'SlugField',
'TagFilterField',
)
@@ -128,24 +126,3 @@ class MACAddressField(forms.Field):
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
return value
-
-
-#
-# Choice fields
-#
-
-class ChoiceField(forms.ChoiceField):
- """
- Previously used to override Django's built-in `ChoiceField` to use NetBox's now-obsolete `StaticSelect` widget.
- """
- # TODO: Remove in v3.6
- pass
-
-
-class MultipleChoiceField(forms.MultipleChoiceField):
- """
- Previously used to override Django's built-in `MultipleChoiceField` to use NetBox's now-obsolete
- `StaticSelectMultiple` widget.
- """
- # TODO: Remove in v3.6
- pass
diff --git a/netbox/utilities/forms/widgets/misc.py b/netbox/utilities/forms/widgets/misc.py
index ca2e64319..e999af831 100644
--- a/netbox/utilities/forms/widgets/misc.py
+++ b/netbox/utilities/forms/widgets/misc.py
@@ -1,6 +1,7 @@
from django import forms
__all__ = (
+ 'ArrayWidget',
'ClearableFileInput',
'MarkdownWidget',
'NumberWithOptions',
@@ -43,3 +44,13 @@ class SlugWidget(forms.TextInput):
Subclass TextInput and add a slug regeneration button next to the form field.
"""
template_name = 'widgets/sluginput.html'
+
+
+class ArrayWidget(forms.Textarea):
+ """
+ Render each item of an array on a new line within a textarea for easy editing/
+ """
+ def format_value(self, value):
+ if value is None or not len(value):
+ return None
+ return '\n'.join(value)
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index b20aafce0..813a8f944 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -18,11 +18,10 @@ def get_permission_for_model(model, action):
:param model: A model or instance
:param action: View, add, change, or delete (string)
"""
- return '{}.{}_{}'.format(
- model._meta.app_label,
- action,
- model._meta.model_name
- )
+ # Resolve to the "concrete" model (for proxy models)
+ model = model._meta.concrete_model
+
+ return f'{model._meta.app_label}.{action}_{model._meta.model_name}'
def resolve_permission(name):
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index ba4b28418..50917dd0f 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -1,7 +1,7 @@
from django.db.models import Prefetch, QuerySet
from users.constants import CONSTRAINT_TOKEN_USER
-from utilities.permissions import permission_is_exempt, qs_filter_from_constraints
+from utilities.permissions import get_permission_for_model, permission_is_exempt, qs_filter_from_constraints
__all__ = (
'RestrictedPrefetch',
@@ -46,9 +46,7 @@ class RestrictedQuerySet(QuerySet):
:param action: The action which must be permitted (e.g. "view" for "dcim.view_site"); default is 'view'
"""
# Resolve the full name of the required permission
- app_label = self.model._meta.app_label
- model_name = self.model._meta.model_name
- permission_required = f'{app_label}.{action}_{model_name}'
+ permission_required = get_permission_for_model(self.model, action)
# Bypass restriction for superusers and exempt views
if user.is_superuser or permission_is_exempt(permission_required):
diff --git a/netbox/utilities/templates/builtins/copy_content.html b/netbox/utilities/templates/builtins/copy_content.html
new file mode 100644
index 000000000..9025a71a1
--- /dev/null
+++ b/netbox/utilities/templates/builtins/copy_content.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/netbox/utilities/templates/buttons/bookmark.html b/netbox/utilities/templates/buttons/bookmark.html
new file mode 100644
index 000000000..b11d1e82e
--- /dev/null
+++ b/netbox/utilities/templates/buttons/bookmark.html
@@ -0,0 +1,15 @@
+
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index f9fe5f4e3..35aec1000 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -6,6 +6,7 @@ from utilities.utils import dict_to_querydict
__all__ = (
'badge',
'checkmark',
+ 'copy_content',
'customfield_value',
'tag',
)
@@ -79,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
}
+@register.inclusion_tag('builtins/copy_content.html')
+def copy_content(target, prefix=None, color='primary'):
+ """
+ Display a copy button to copy the content of a field.
+ """
+ return {
+ 'target': f'#{prefix or ""}{target}',
+ 'color': f'btn-{color}'
+ }
+
+
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
def htmx_table(context, viewname, return_url=None, **kwargs):
"""
diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py
index 1556b29a0..828af3b43 100644
--- a/netbox/utilities/templatetags/buttons.py
+++ b/netbox/utilities/templatetags/buttons.py
@@ -2,11 +2,12 @@ from django import template
from django.contrib.contenttypes.models import ContentType
from django.urls import NoReverseMatch, reverse
-from extras.models import ExportTemplate
+from extras.models import Bookmark, ExportTemplate
from utilities.utils import get_viewname, prepare_cloned_fields
__all__ = (
'add_button',
+ 'bookmark_button',
'bulk_delete_button',
'bulk_edit_button',
'clone_button',
@@ -24,6 +25,37 @@ register = template.Library()
# Instance buttons
#
+@register.inclusion_tag('buttons/bookmark.html', takes_context=True)
+def bookmark_button(context, instance):
+ # Check if this user has already bookmarked the object
+ content_type = ContentType.objects.get_for_model(instance)
+ bookmark = Bookmark.objects.filter(
+ object_type=content_type,
+ object_id=instance.pk,
+ user=context['request'].user
+ ).first()
+
+ # Compile form URL & data
+ if bookmark:
+ form_url = reverse('extras:bookmark_delete', kwargs={'pk': bookmark.pk})
+ form_data = {
+ 'confirm': 'true',
+ }
+ else:
+ form_url = reverse('extras:bookmark_add')
+ form_data = {
+ 'object_type': content_type.pk,
+ 'object_id': instance.pk,
+ }
+
+ return {
+ 'bookmark': bookmark,
+ 'form_url': form_url,
+ 'form_data': form_data,
+ 'return_url': instance.get_absolute_url(),
+ }
+
+
@register.inclusion_tag('buttons/clone.html')
def clone_button(instance):
url = reverse(get_viewname(instance, 'add'))
diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py
index 7f24c86b8..8cfe1cdd7 100644
--- a/netbox/utilities/testing/api.py
+++ b/netbox/utilities/testing/api.py
@@ -2,7 +2,7 @@ import inspect
import json
from django.conf import settings
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.test import override_settings
@@ -26,6 +26,9 @@ __all__ = (
)
+User = get_user_model()
+
+
#
# REST/GraphQL API Tests
#
diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py
index 04ceca1e2..76a9fac06 100644
--- a/netbox/utilities/testing/base.py
+++ b/netbox/utilities/testing/base.py
@@ -1,6 +1,6 @@
import json
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import FieldDoesNotExist
@@ -27,7 +27,7 @@ class TestCase(_TestCase):
def setUp(self):
# Create the test user and assign permissions
- self.user = User.objects.create_user(username='testuser')
+ self.user = get_user_model().objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions)
# Initialize the test client
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 52ccd002d..87fc3319c 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -2,7 +2,8 @@ import logging
import re
from contextlib import contextmanager
-from django.contrib.auth.models import Permission, User
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
from django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@@ -63,7 +64,7 @@ def create_test_user(username='testuser', permissions=None):
"""
Create a User with the given permissions.
"""
- user = User.objects.create_user(username=username)
+ user = get_user_model().objects.create_user(username=username)
if permissions is None:
permissions = ()
for perm_name in permissions:
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index dc17548a2..539fe3057 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -1,5 +1,6 @@
import csv
+from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ForeignKey
@@ -64,8 +65,15 @@ class ViewTestCases:
def test_get_object_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
- response = self.client.get(self._get_queryset().first().get_absolute_url())
- self.assertHttpStatus(response, 200)
+ ct = ContentType.objects.get_for_model(self.model)
+ if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS:
+ # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
+ with disable_warnings('django.request'):
+ response = self.client.get(self._get_queryset().first().get_absolute_url())
+ self.assertHttpStatus(response, 302)
+ else:
+ response = self.client.get(self._get_queryset().first().get_absolute_url())
+ self.assertHttpStatus(response, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object_without_permission(self):
@@ -128,6 +136,7 @@ class ViewTestCases:
:form_data: Data to be used when creating a new object.
"""
form_data = {}
+ validation_excluded_fields = []
def test_create_object_without_permission(self):
@@ -146,7 +155,6 @@ class ViewTestCases:
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_create_object_with_permission(self):
- initial_count = self._get_queryset().count()
# Assign unconstrained permission
obj_perm = ObjectPermission(
@@ -161,6 +169,7 @@ class ViewTestCases:
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
# Try POST with model-level permission
+ initial_count = self._get_queryset().count()
request = {
'path': self._get_url('add'),
'data': post_data(self.form_data),
@@ -168,19 +177,19 @@ class ViewTestCases:
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(initial_count + 1, self._get_queryset().count())
instance = self._get_queryset().order_by('pk').last()
- self.assertInstanceEqual(instance, self.form_data)
+ self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
# Verify ObjectChange creation
- objectchanges = ObjectChange.objects.filter(
- changed_object_type=ContentType.objects.get_for_model(instance),
- changed_object_id=instance.pk
- )
- self.assertEqual(len(objectchanges), 1)
- self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
+ if issubclass(instance.__class__, ChangeLoggingMixin):
+ objectchanges = ObjectChange.objects.filter(
+ changed_object_type=ContentType.objects.get_for_model(instance),
+ changed_object_id=instance.pk
+ )
+ self.assertEqual(len(objectchanges), 1)
+ self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_CREATE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_create_object_with_constrained_permission(self):
- initial_count = self._get_queryset().count()
# Assign constrained permission
obj_perm = ObjectPermission(
@@ -196,6 +205,7 @@ class ViewTestCases:
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
# Try to create an object (not permitted)
+ initial_count = self._get_queryset().count()
request = {
'path': self._get_url('add'),
'data': post_data(self.form_data),
@@ -214,7 +224,8 @@ class ViewTestCases:
}
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(initial_count + 1, self._get_queryset().count())
- self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data)
+ instance = self._get_queryset().order_by('pk').last()
+ self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
class EditObjectViewTestCase(ModelViewTestCase):
"""
@@ -223,6 +234,7 @@ class ViewTestCases:
:form_data: Data to be used when updating the first existing object.
"""
form_data = {}
+ validation_excluded_fields = []
def test_edit_object_without_permission(self):
instance = self._get_queryset().first()
@@ -261,15 +273,17 @@ class ViewTestCases:
'data': post_data(self.form_data),
}
self.assertHttpStatus(self.client.post(**request), 302)
- self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data)
+ instance = self._get_queryset().get(pk=instance.pk)
+ self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
# Verify ObjectChange creation
- objectchanges = ObjectChange.objects.filter(
- changed_object_type=ContentType.objects.get_for_model(instance),
- changed_object_id=instance.pk
- )
- self.assertEqual(len(objectchanges), 1)
- self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
+ if issubclass(instance.__class__, ChangeLoggingMixin):
+ objectchanges = ObjectChange.objects.filter(
+ changed_object_type=ContentType.objects.get_for_model(instance),
+ changed_object_id=instance.pk
+ )
+ self.assertEqual(len(objectchanges), 1)
+ self.assertEqual(objectchanges[0].action, ObjectChangeActionChoices.ACTION_UPDATE)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_edit_object_with_constrained_permission(self):
@@ -297,7 +311,8 @@ class ViewTestCases:
'data': post_data(self.form_data),
}
self.assertHttpStatus(self.client.post(**request), 302)
- self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data)
+ instance = self._get_queryset().get(pk=instance1.pk)
+ self.assertInstanceEqual(instance, self.form_data, exclude=self.validation_excluded_fields)
# Try to edit a non-permitted object
request = {
@@ -404,8 +419,15 @@ class ViewTestCases:
def test_list_objects_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
- response = self.client.get(self._get_url('list'))
- self.assertHttpStatus(response, 200)
+ ct = ContentType.objects.get_for_model(self.model)
+ if (ct.app_label, ct.model) in settings.EXEMPT_EXCLUDE_MODELS:
+ # Models listed in EXEMPT_EXCLUDE_MODELS should not be accessible to anonymous users
+ with disable_warnings('django.request'):
+ response = self.client.get(self._get_url('list'))
+ self.assertHttpStatus(response, 302)
+ else:
+ response = self.client.get(self._get_url('list'))
+ self.assertHttpStatus(response, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_without_permission(self):
@@ -450,10 +472,19 @@ class ViewTestCases:
self.assertIn(instance1.get_absolute_url(), content)
self.assertNotIn(instance2.get_absolute_url(), content)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_export_objects(self):
url = self._get_url('list')
+ # Add model-level permission
+ obj_perm = ObjectPermission(
+ name='Test permission',
+ actions=['view']
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
# Test default CSV export
response = self.client.get(f'{url}?export')
self.assertHttpStatus(response, 200)
@@ -700,7 +731,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
name='Test permission',
- actions=['change']
+ actions=['view', 'change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -731,7 +762,7 @@ class ViewTestCases:
obj_perm = ObjectPermission(
name='Test permission',
constraints={attr_name: value},
- actions=['change']
+ actions=['view', 'change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -795,7 +826,6 @@ class ViewTestCases:
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects_with_constrained_permission(self):
- initial_count = self._get_queryset().count()
pk_list = self._get_queryset().values_list('pk', flat=True)
data = {
'pk': pk_list,
@@ -814,6 +844,7 @@ class ViewTestCases:
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Attempt to bulk delete non-permitted objects
+ initial_count = self._get_queryset().count()
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count)
diff --git a/requirements.txt b/requirements.txt
index e6e56ce56..f707c60c5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
bleach==6.0.0
-boto3==1.26.156
-Django==4.1.9
-django-cors-headers==4.1.0
+boto3==1.28.1
+Django==4.2.2
+django-cors-headers==4.2.0
django-debug-toolbar==4.1.0
django-filter==23.2
django-graphiql-debug-toolbar==0.2.0
@@ -9,27 +9,27 @@ django-mptt==0.14
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.3.0
-django-rich==1.6.0
+django-rich==1.7.0
django-rq==2.8.1
-django-tables2==2.5.3
+django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==5.1
djangorestframework==3.14.0
-drf-spectacular==0.26.2
-drf-spectacular-sidecar==2023.6.1
+drf-spectacular==0.26.3
+drf-spectacular-sidecar==2023.7.1
dulwich==0.21.5
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
-mkdocs-material==9.1.16
+mkdocs-material==9.1.18
mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0
-Pillow==9.5.0
-psycopg2-binary==2.9.6
+Pillow==10.0.0
+psycopg[binary,pool]==3.1.9
PyYAML==6.0
-sentry-sdk==1.25.1
+sentry-sdk==1.28.0
social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3
|