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 "Name" %} |
+ {{ object.name }} |
+
+
+ | {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+
+
+
+
+
+
+ {% for group in object.groups.all %}
+
{{ group }}
+ {% empty %}
+
{% trans "None" %}
+ {% endfor %}
+
+
+
+
+
+ {% 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