Introduce the Owner model

This commit is contained in:
Jeremy Stretch
2025-10-16 15:27:47 -04:00
parent 77c08b7bf9
commit 8fd88b357e
21 changed files with 1475 additions and 14 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,46 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Owner" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,3 +1,4 @@
from .serializers_.users import *
from .serializers_.permissions import *
from .serializers_.tokens import *
from .serializers_.owners import *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,3 +2,4 @@ from .users import *
from .preferences import *
from .tokens import *
from .permissions import *
from .owners import *

View File

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

View File

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

View File

@@ -18,4 +18,7 @@ urlpatterns = [
path('permissions/', include(get_model_urls('users', 'objectpermission', detail=False))),
path('permissions/<int:pk>/', include(get_model_urls('users', 'objectpermission'))),
path('owners/', include(get_model_urls('users', 'owner', detail=False))),
path('owners/<int:pk>/', include(get_model_urls('users', 'owner'))),
]

View File

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