From 8fd88b357e8ccb9ba3419f83e4a273868a2db64a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Oct 2025 15:27:47 -0400 Subject: [PATCH] Introduce the Owner model --- contrib/openapi.json | 1057 +++++++++++++++++++++++ netbox/netbox/navigation/menu.py | 6 + netbox/templates/users/owner.html | 46 + netbox/users/api/serializers.py | 1 + netbox/users/api/serializers_/owners.py | 30 + netbox/users/api/urls.py | 8 +- netbox/users/api/views.py | 12 +- netbox/users/filtersets.py | 44 +- netbox/users/forms/bulk_edit.py | 19 + netbox/users/forms/bulk_import.py | 21 + netbox/users/forms/filtersets.py | 21 +- netbox/users/forms/model_forms.py | 17 +- netbox/users/graphql/filters.py | 9 + netbox/users/graphql/schema.py | 3 + netbox/users/graphql/types.py | 13 +- netbox/users/migrations/0015_owner.py | 43 + netbox/users/models/__init__.py | 1 + netbox/users/models/owners.py | 49 ++ netbox/users/tables.py | 27 +- netbox/users/urls.py | 3 + netbox/users/views.py | 59 +- 21 files changed, 1475 insertions(+), 14 deletions(-) create mode 100644 netbox/templates/users/owner.html create mode 100644 netbox/users/api/serializers_/owners.py create mode 100644 netbox/users/migrations/0015_owner.py create mode 100644 netbox/users/models/owners.py diff --git a/contrib/openapi.json b/contrib/openapi.json index 4e159af9a..14eba1d6e 100644 --- a/contrib/openapi.json +++ b/contrib/openapi.json @@ -164474,6 +164474,924 @@ } } }, + "/api/users/owners/": { + "get": { + "operationId": "users_owners_list", + "description": "Get a list of owner objects.", + "parameters": [ + { + "in": "query", + "name": "description", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "description__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "description__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "group", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "group__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Group (name)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "group_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "group_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Group (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "id__gt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__gte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lt", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__lte", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "id__n", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results to return per page.", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__empty", + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "name__ic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__ie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__iregex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__isw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nic", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nie", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__niew", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__nisw", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "name__regex", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "explode": true, + "style": "form" + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "The initial index from which to return the results.", + "schema": { + "type": "integer" + } + }, + { + "name": "ordering", + "required": false, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "schema": { + "type": "string" + }, + "description": "Search" + }, + { + "in": "query", + "name": "user", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "User (username)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "user__n", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "User (username)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "user_id", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "User (ID)", + "explode": true, + "style": "form" + }, + { + "in": "query", + "name": "user_id__n", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "User (ID)", + "explode": true, + "style": "form" + } + ], + "tags": [ + "users" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedOwnerList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "users_owners_create", + "description": "Post a list of owner objects.", + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Owner" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "users_owners_bulk_update", + "description": "Put a list of owner objects.", + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Owner" + } + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "users_owners_bulk_partial_update", + "description": "Patch a list of owner objects.", + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Owner" + } + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "users_owners_bulk_destroy", + "description": "Delete a list of owner objects.", + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + }, + "multipart/form-data": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/users/owners/{id}/": { + "get": { + "operationId": "users_owners_retrieve", + "description": "Get a owner object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this owner.", + "required": true + } + ], + "tags": [ + "users" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Owner" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "users_owners_update", + "description": "Put a owner object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this owner.", + "required": true + } + ], + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Owner" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "users_owners_partial_update", + "description": "Patch a owner object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this owner.", + "required": true + } + ], + "tags": [ + "users" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedOwnerRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedOwnerRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Owner" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "users_owners_destroy", + "description": "Delete a owner object.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "description": "A unique integer value identifying this owner.", + "required": true + } + ], + "tags": [ + "users" + ], + "security": [ + { + "cookieAuth": [] + }, + { + "tokenAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, "/api/users/permissions/": { "get": { "operationId": "users_permissions_list", @@ -221996,6 +222914,87 @@ "url" ] }, + "Owner": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display_url": { + "type": "string", + "format": "uri", + "readOnly": true + }, + "display": { + "type": "string", + "readOnly": true + }, + "name": { + "type": "string", + "maxLength": 150 + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Group" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + }, + "required": [ + "display", + "display_url", + "id", + "name", + "url" + ] + }, + "OwnerRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 150 + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "groups": { + "type": "array", + "items": { + "type": "integer" + } + }, + "users": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "name" + ] + }, "PaginatedASNList": { "type": "object", "required": [ @@ -224228,6 +225227,37 @@ } } }, + "PaginatedOwnerList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=400&limit=100" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?offset=200&limit=100" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Owner" + } + } + } + }, "PaginatedPlatformList": { "type": "object", "required": [ @@ -227533,6 +228563,33 @@ } } }, + "PatchedOwnerRequest": { + "type": "object", + "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 150 + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "groups": { + "type": "array", + "items": { + "type": "integer" + } + }, + "users": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "PatchedPowerPanelRequest": { "type": "object", "description": "Adds support for custom fields and tags.", diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index ac4c2b492..0c7fc22c1 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -36,6 +36,12 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['bulk_import']), ), ), + MenuGroup( + label=_('Ownership'), + items=( + get_model_item('users', 'owner', _('Owners')), + ), + ), ), ) diff --git a/netbox/templates/users/owner.html b/netbox/templates/users/owner.html new file mode 100644 index 000000000..b840c3b67 --- /dev/null +++ b/netbox/templates/users/owner.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load i18n %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
+
+
+

{% trans "Owner" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+
+
+

{% trans "Groups" %}

+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+

{% trans "Users" %}

+
+ {% for user in object.users.all %} + {{ user }} + {% empty %} +
{% trans "None" %}
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 700061b8c..9e64515c2 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ from .serializers_.users import * from .serializers_.permissions import * from .serializers_.tokens import * +from .serializers_.owners import * diff --git a/netbox/users/api/serializers_/owners.py b/netbox/users/api/serializers_/owners.py new file mode 100644 index 000000000..b67d5b6c8 --- /dev/null +++ b/netbox/users/api/serializers_/owners.py @@ -0,0 +1,30 @@ +from netbox.api.fields import SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer +from users.models import Group, Owner, User +from .users import GroupSerializer, UserSerializer + +__all__ = ( + 'OwnerSerializer', +) + + +class OwnerSerializer(ValidatedModelSerializer): + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=GroupSerializer, + nested=True, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=UserSerializer, + nested=True, + required=False, + many=True + ) + + class Meta: + model = Owner + fields = ('id', 'url', 'display_url', 'display', 'name', 'description', 'groups', 'users') + brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index 599d0bb61..87a5fde09 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -7,17 +7,11 @@ from . import views router = NetBoxRouter() router.APIRootView = views.UsersRootView -# Users and groups router.register('users', views.UserViewSet) router.register('groups', views.GroupViewSet) - -# Tokens router.register('tokens', views.TokenViewSet) - -# Permissions router.register('permissions', views.ObjectPermissionViewSet) - -# User preferences +router.register('owners', views.OwnerViewSet) router.register('config', views.UserConfigViewSet, basename='userconfig') app_name = 'users-api' diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index bba9a4ec3..651c2c8a7 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -12,7 +12,7 @@ from rest_framework.viewsets import ViewSet from netbox.api.viewsets import NetBoxModelViewSet from users import filtersets -from users.models import Group, ObjectPermission, Token, User, UserConfig +from users.models import Group, ObjectPermission, Owner, Token, User, UserConfig from utilities.data import deepmerge from utilities.querysets import RestrictedQuerySet from . import serializers @@ -88,6 +88,16 @@ class ObjectPermissionViewSet(NetBoxModelViewSet): filterset_class = filtersets.ObjectPermissionFilterSet +# +# Owners +# + +class OwnerViewSet(NetBoxModelViewSet): + queryset = Owner.objects.all() + serializer_class = serializers.OwnerSerializer + filterset_class = filtersets.OwnerFilterSet + + # # User preferences # diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 36fbdcb0d..8d9abd3dc 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -6,12 +6,13 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.models import NotificationGroup from netbox.filtersets import BaseFilterSet -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, Token, User from utilities.filters import ContentTypeFilter __all__ = ( 'GroupFilterSet', 'ObjectPermissionFilterSet', + 'OwnerFilterSet', 'TokenFilterSet', 'UserFilterSet', ) @@ -221,3 +222,44 @@ class ObjectPermissionFilterSet(BaseFilterSet): return queryset.filter(actions__contains=[action]) else: return queryset.exclude(actions__contains=[action]) + + +class OwnerFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label=_('Group (ID)'), + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label=_('Group (name)'), + ) + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='users', + queryset=User.objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='users__username', + queryset=User.objects.all(), + to_field_name='username', + label=_('User (username)'), + ) + + class Meta: + model = Owner + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index bca417b3d..a31593e73 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -12,6 +12,7 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker __all__ = ( 'GroupBulkEditForm', 'ObjectPermissionBulkEditForm', + 'OwnerBulkEditForm', 'UserBulkEditForm', 'TokenBulkEditForm', ) @@ -124,3 +125,21 @@ class TokenBulkEditForm(BulkEditForm): nullable_fields = ( 'expires', 'description', 'allowed_ips', ) + + +class OwnerBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Owner.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + + model = Owner + fieldsets = ( + FieldSet('description',), + ) + nullable_fields = ('description',) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index bdda61a44..045461239 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -3,10 +3,12 @@ from django.utils.translation import gettext as _ from users.models import * from users.choices import TokenVersionChoices from utilities.forms import CSVModelForm +from utilities.forms.fields import CSVModelMultipleChoiceField __all__ = ( 'GroupImportForm', + 'OwnerImportForm', 'UserImportForm', 'TokenImportForm', ) @@ -50,3 +52,22 @@ class TokenImportForm(CSVModelForm): class Meta: model = Token fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',) + + +class OwnerImportForm(CSVModelForm): + groups = CSVModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + to_field_name='name', + ) + users = CSVModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + to_field_name='username', + ) + + class Meta: + model = Owner + fields = ( + 'name', 'description', 'groups', 'users', + ) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index 32e52b5f9..96a7eb317 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from users.choices import TokenVersionChoices -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, Token, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet @@ -14,6 +14,7 @@ from utilities.forms.widgets import DateTimePicker __all__ = ( 'GroupFilterForm', 'ObjectPermissionFilterForm', + 'OwnerFilterForm', 'TokenFilterForm', 'UserFilterForm', ) @@ -140,3 +141,21 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm): label=_('Last Used'), widget=DateTimePicker() ) + + +class OwnerFilterForm(NetBoxModelFilterSetForm): + model = Owner + fieldsets = ( + FieldSet('q', 'filter_id',), + FieldSet('group_id', 'user_id', name=_('Members')), + ) + group_id = DynamicModelMultipleChoiceField( + queryset=Group.objects.all(), + required=False, + label=_('Group') + ) + user_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User') + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index cae194331..4656129b5 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -23,11 +23,11 @@ from utilities.permissions import qs_filter_from_constraints __all__ = ( 'GroupForm', 'ObjectPermissionForm', + 'OwnerForm', 'TokenForm', 'UserConfigForm', 'UserForm', 'UserTokenForm', - 'TokenForm', ) @@ -431,3 +431,18 @@ class ObjectPermissionForm(forms.ModelForm): instance.groups.set(self.cleaned_data['groups']) return instance + + +class OwnerForm(forms.ModelForm): + + fieldsets = ( + FieldSet('name', 'description', name=_('Owner')), + FieldSet('groups', name=_('Groups')), + FieldSet('users', name=_('Users')), + ) + + class Meta: + model = Owner + fields = [ + 'name', 'description', 'groups', 'users', + ] diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index 07f28bb88..bfec7d5fc 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -10,6 +10,7 @@ from users import models __all__ = ( 'GroupFilter', + 'OwnerFilter', 'UserFilter', ) @@ -31,3 +32,11 @@ class UserFilter(BaseObjectTypeFilterMixin): date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.Owner, lookups=True) +class OwnerFilter(BaseObjectTypeFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index b59266c57..cb35f9284 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -13,3 +13,6 @@ class UsersQuery: user: UserType = strawberry_django.field() user_list: List[UserType] = strawberry_django.field() + + owner: OwnerType = strawberry_django.field() + owner_list: List[OwnerType] = strawberry_django.field() diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 5231194e5..d8edfcb44 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -3,11 +3,12 @@ from typing import List import strawberry_django from netbox.graphql.types import BaseObjectType -from users.models import Group, User +from users.models import Group, Owner, User from .filters import * __all__ = ( 'GroupType', + 'OwnerType', 'UserType', ) @@ -32,3 +33,13 @@ class GroupType(BaseObjectType): ) class UserType(BaseObjectType): groups: List[GroupType] + + +@strawberry_django.type( + Owner, + fields=['id', 'name', 'description', 'groups', 'users'], + filters=OwnerFilter, + pagination=True +) +class OwnerType(BaseObjectType): + pass diff --git a/netbox/users/migrations/0015_owner.py b/netbox/users/migrations/0015_owner.py new file mode 100644 index 000000000..cec3034e2 --- /dev/null +++ b/netbox/users/migrations/0015_owner.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_users_token_v2'), + ] + + operations = [ + migrations.CreateModel( + name='Owner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ( + 'groups', + models.ManyToManyField( + blank=True, + related_name='owners', + related_query_name='owner', + to='users.group', + ) + ), + ( + 'users', + models.ManyToManyField( + blank=True, + related_name='owners', + related_query_name='owner', + to=settings.AUTH_USER_MODEL, + ) + ), + ], + options={ + 'verbose_name': 'owner', + 'verbose_name_plural': 'owners', + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/users/models/__init__.py b/netbox/users/models/__init__.py index 62a7b93fe..c6223e996 100644 --- a/netbox/users/models/__init__.py +++ b/netbox/users/models/__init__.py @@ -2,3 +2,4 @@ from .users import * from .preferences import * from .tokens import * from .permissions import * +from .owners import * diff --git a/netbox/users/models/owners.py b/netbox/users/models/owners.py new file mode 100644 index 000000000..6765d3034 --- /dev/null +++ b/netbox/users/models/owners.py @@ -0,0 +1,49 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Owner', +) + + +class Owner(models.Model): + name = models.CharField( + verbose_name=_('name'), + max_length=150, + unique=True, + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + groups = models.ManyToManyField( + to='users.Group', + verbose_name=_('groups'), + blank=True, + related_name='owners', + related_query_name='owner', + ) + users = models.ManyToManyField( + to='users.User', + verbose_name=_('users'), + blank=True, + related_name='owners', + related_query_name='owner', + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('name',) + verbose_name = _('owner') + verbose_name_plural = _('owners') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('users:owner', args=[self.pk]) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 2b4bd745f..17460dc77 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -2,11 +2,12 @@ import django_tables2 as tables from django.utils.translation import gettext as _ from netbox.tables import NetBoxTable, columns -from users.models import Group, ObjectPermission, Token, User +from users.models import Group, ObjectPermission, Owner, Token, User __all__ = ( 'GroupTable', 'ObjectPermissionTable', + 'OwnerTable', 'TokenTable', 'UserTable', ) @@ -143,3 +144,27 @@ class ObjectPermissionTable(NetBoxTable): default_columns = ( 'pk', 'name', 'enabled', 'object_types', 'can_view', 'can_add', 'can_change', 'can_delete', 'description', ) + + +class OwnerTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + groups = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('users:group', {'pk': tables.A('pk')}) + ) + users = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('users:group', {'pk': tables.A('pk')}) + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(NetBoxTable.Meta): + model = Owner + fields = ( + 'pk', 'id', 'name', 'description', 'groups', 'users', + ) diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 83f120702..9fa24bc7e 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -18,4 +18,7 @@ urlpatterns = [ path('permissions/', include(get_model_urls('users', 'objectpermission', detail=False))), path('permissions//', include(get_model_urls('users', 'objectpermission'))), + path('owners/', include(get_model_urls('users', 'owner', detail=False))), + path('owners//', include(get_model_urls('users', 'owner'))), + ] diff --git a/netbox/users/views.py b/netbox/users/views.py index 9071c6c8b..60d1cdfc1 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,7 +6,7 @@ from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, B from netbox.views import generic from utilities.views import register_model_view from . import filtersets, forms, tables -from .models import Group, User, ObjectPermission, Token +from .models import Group, User, ObjectPermission, Owner, Token # @@ -231,3 +231,60 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView): queryset = ObjectPermission.objects.all() filterset = filtersets.ObjectPermissionFilterSet table = tables.ObjectPermissionTable + + +# +# Owners +# + +@register_model_view(Owner, 'list', path='', detail=False) +class OwnerListView(generic.ObjectListView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + filterset_form = forms.OwnerFilterForm + table = tables.OwnerTable + + +@register_model_view(Owner) +class OwnerView(generic.ObjectView): + queryset = Owner.objects.all() + template_name = 'users/owner.html' + + +@register_model_view(Owner, 'add', detail=False) +@register_model_view(Owner, 'edit') +class OwnerEditView(generic.ObjectEditView): + queryset = Owner.objects.all() + form = forms.OwnerForm + + +@register_model_view(Owner, 'delete') +class OwnerDeleteView(generic.ObjectDeleteView): + queryset = Owner.objects.all() + + +@register_model_view(Owner, 'bulk_import', path='import', detail=False) +class OwnerBulkImportView(generic.BulkImportView): + queryset = Owner.objects.all() + model_form = forms.OwnerImportForm + + +@register_model_view(Owner, 'bulk_edit', path='edit', detail=False) +class OwnerBulkEditView(generic.BulkEditView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + table = tables.OwnerTable + form = forms.OwnerBulkEditForm + + +@register_model_view(Owner, 'bulk_rename', path='rename', detail=False) +class OwnerBulkRenameView(generic.BulkRenameView): + queryset = Owner.objects.all() + field_name = 'ownername' + + +@register_model_view(Owner, 'bulk_delete', path='delete', detail=False) +class OwnerBulkDeleteView(generic.BulkDeleteView): + queryset = Owner.objects.all() + filterset = filtersets.OwnerFilterSet + table = tables.OwnerTable