Introduce OwnerGroup model
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run

This commit is contained in:
Jeremy Stretch
2025-10-22 16:34:27 -04:00
parent 3ca2a18a3f
commit 1a6ea31538
20 changed files with 6486 additions and 52 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -468,6 +468,7 @@ ADMIN_MENU = Menu(
MenuGroup(
label=_('Ownership'),
items=(
get_model_item('users', 'ownergroup', _('Owner Groups')),
get_model_item('users', 'owner', _('Owners')),
),
),

View File

@@ -1,6 +1,15 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.group %}
<li class="breadcrumb-item">
<a href="{% url 'users:owner_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
</li>
{% endif %}
{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
@@ -13,6 +22,10 @@
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
@@ -22,7 +35,7 @@
<div class="card">
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
{% for group in object.user_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>

View File

@@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Group" %}</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 "Members" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.members.all %}
<a href="{% url 'users:owner' pk=user.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,15 +1,30 @@
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
from users.models import Group, Owner, User
from users.models import Group, Owner, OwnerGroup, User
from .users import GroupSerializer, UserSerializer
__all__ = (
'OwnerGroupSerializer',
'OwnerSerializer',
)
class OwnerGroupSerializer(ValidatedModelSerializer):
# Related object counts
member_count = RelatedObjectCountField('members')
class Meta:
model = OwnerGroup
fields = ('id', 'url', 'display_url', 'display', 'name', 'description', 'member_count')
brief_fields = ('id', 'url', 'display', 'name', 'description')
class OwnerSerializer(ValidatedModelSerializer):
groups = SerializedPKRelatedField(
group = OwnerGroupSerializer(
nested=True,
allow_null=True,
)
user_groups = SerializedPKRelatedField(
queryset=Group.objects.all(),
serializer=GroupSerializer,
nested=True,
@@ -26,5 +41,5 @@ class OwnerSerializer(ValidatedModelSerializer):
class Meta:
model = Owner
fields = ('id', 'url', 'display_url', 'display', 'name', 'description', 'groups', 'users')
fields = ('id', 'url', 'display_url', 'display', 'name', 'group', 'description', 'user_groups', 'users')
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -11,6 +11,7 @@ router.register('users', views.UserViewSet)
router.register('groups', views.GroupViewSet)
router.register('tokens', views.TokenViewSet)
router.register('permissions', views.ObjectPermissionViewSet)
router.register('owner-groups', views.OwnerGroupViewSet)
router.register('owners', views.OwnerViewSet)
router.register('config', views.UserConfigViewSet, basename='userconfig')

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, Owner, Token, User, UserConfig
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User, UserConfig
from utilities.data import deepmerge
from utilities.querysets import RestrictedQuerySet
from . import serializers
@@ -92,6 +92,12 @@ class ObjectPermissionViewSet(NetBoxModelViewSet):
# Owners
#
class OwnerGroupViewSet(NetBoxModelViewSet):
queryset = OwnerGroup.objects.all()
serializer_class = serializers.OwnerGroupSerializer
filterset_class = filtersets.OwnerGroupFilterSet
class OwnerViewSet(NetBoxModelViewSet):
queryset = Owner.objects.all()
serializer_class = serializers.OwnerSerializer

View File

@@ -6,13 +6,14 @@ 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, Owner, Token, User
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
from utilities.filters import ContentTypeFilter
__all__ = (
'GroupFilterSet',
'ObjectPermissionFilterSet',
'OwnerFilterSet',
'OwnerGroupFilterSet',
'TokenFilterSet',
'UserFilterSet',
)
@@ -246,22 +247,51 @@ class ObjectPermissionFilterSet(BaseFilterSet):
return queryset.exclude(actions__contains=[action])
class OwnerGroupFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = OwnerGroup
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)
)
class OwnerFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
field_name='groups',
queryset=Group.objects.all(),
queryset=OwnerGroup.objects.all(),
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='groups__name',
queryset=Group.objects.all(),
field_name='group__name',
queryset=OwnerGroup.objects.all(),
to_field_name='name',
label=_('Group (name)'),
)
user_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='user_groups',
queryset=Group.objects.all(),
label=_('User group (ID)'),
)
user_group = django_filters.ModelMultipleChoiceFilter(
field_name='user_groups__name',
queryset=Group.objects.all(),
to_field_name='name',
label=_('User group (name)'),
)
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users',
queryset=User.objects.all(),

View File

@@ -6,6 +6,7 @@ from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from users.models import *
from utilities.forms import BulkEditForm
from utilities.forms.fields import DynamicModelChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker
@@ -13,6 +14,7 @@ __all__ = (
'GroupBulkEditForm',
'ObjectPermissionBulkEditForm',
'OwnerBulkEditForm',
'OwnerGroupBulkEditForm',
'UserBulkEditForm',
'TokenBulkEditForm',
)
@@ -127,11 +129,34 @@ class TokenBulkEditForm(BulkEditForm):
)
class OwnerGroupBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=OwnerGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = OwnerGroup
fieldsets = (
FieldSet('description',),
)
nullable_fields = ('description',)
class OwnerBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Owner.objects.all(),
widget=forms.MultipleHiddenInput
)
group = DynamicModelChoiceField(
label=_('Group'),
queryset=OwnerGroup.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
@@ -140,6 +165,6 @@ class OwnerBulkEditForm(BulkEditForm):
model = Owner
fieldsets = (
FieldSet('description',),
FieldSet('group', 'description'),
)
nullable_fields = ('description',)
nullable_fields = ('group', 'description',)

View File

@@ -3,11 +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
from utilities.forms.fields import CSVModelChoiceField, CSVModelMultipleChoiceField
__all__ = (
'GroupImportForm',
'OwnerGroupImportForm',
'OwnerImportForm',
'UserImportForm',
'TokenImportForm',
@@ -54,8 +55,22 @@ class TokenImportForm(CSVModelForm):
fields = ('user', 'version', 'token', 'write_enabled', 'expires', 'description',)
class OwnerGroupImportForm(CSVModelForm):
class Meta:
model = OwnerGroup
fields = (
'name', 'description',
)
class OwnerImportForm(CSVModelForm):
groups = CSVModelMultipleChoiceField(
group = CSVModelChoiceField(
queryset=OwnerGroup.objects.all(),
required=False,
to_field_name='name',
)
user_groups = CSVModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
to_field_name='name',
@@ -69,5 +84,5 @@ class OwnerImportForm(CSVModelForm):
class Meta:
model = Owner
fields = (
'name', 'description', 'groups', 'users',
'group', 'name', 'description', 'user_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, Owner, Token, User
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
@@ -15,6 +15,7 @@ __all__ = (
'GroupFilterForm',
'ObjectPermissionFilterForm',
'OwnerFilterForm',
'OwnerGroupFilterForm',
'TokenFilterForm',
'UserFilterForm',
)
@@ -143,19 +144,32 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
)
class OwnerGroupFilterForm(NetBoxModelFilterSetForm):
model = OwnerGroup
fieldsets = (
FieldSet('q', 'filter_id',),
)
class OwnerFilterForm(NetBoxModelFilterSetForm):
model = Owner
fieldsets = (
FieldSet('q', 'filter_id',),
FieldSet('group_id', 'user_id', name=_('Members')),
FieldSet('group_id', name=_('Group')),
FieldSet('user_group_id', 'user_id', name=_('Membership')),
)
group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
queryset=OwnerGroup.objects.all(),
required=False,
label=_('Group')
)
user_group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
label=_('Groups')
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User')
label=_('Users')
)

View File

@@ -15,7 +15,9 @@ from users.choices import TokenVersionChoices
from users.constants import *
from users.models import *
from utilities.data import flatten_dict
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField
from utilities.forms.fields import (
ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
from utilities.permissions import qs_filter_from_constraints
@@ -24,6 +26,7 @@ __all__ = (
'GroupForm',
'ObjectPermissionForm',
'OwnerForm',
'OwnerGroupForm',
'TokenForm',
'UserConfigForm',
'UserForm',
@@ -433,16 +436,35 @@ class ObjectPermissionForm(forms.ModelForm):
return instance
class OwnerForm(forms.ModelForm):
class OwnerGroupForm(forms.ModelForm):
fieldsets = (
FieldSet('name', 'description', name=_('Owner')),
FieldSet('groups', name=_('Groups')),
FieldSet('name', 'description', name=_('Owner Group')),
)
class Meta:
model = OwnerGroup
fields = [
'name', 'description',
]
class OwnerForm(forms.ModelForm):
fieldsets = (
FieldSet('name', 'group', 'description', name=_('Owner')),
FieldSet('user_groups', name=_('Groups')),
FieldSet('users', name=_('Users')),
)
group = DynamicModelChoiceField(
label=_('Group'),
queryset=OwnerGroup.objects.all(),
required=False,
selector=True,
quick_add=True
)
class Meta:
model = Owner
fields = [
'name', 'description', 'groups', 'users',
'name', 'group', 'description', 'user_groups', 'users',
]

View File

@@ -11,6 +11,7 @@ from users import models
__all__ = (
'GroupFilter',
'OwnerFilter',
'OwnerGroupFilter',
'UserFilter',
)
@@ -38,5 +39,16 @@ class UserFilter(BaseObjectTypeFilterMixin):
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()
group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = (
strawberry_django.filter_field()
)
user_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()
@strawberry_django.filter_type(models.OwnerGroup, lookups=True)
class OwnerGroupFilter(BaseObjectTypeFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -14,5 +14,8 @@ class UsersQuery:
user: UserType = strawberry_django.field()
user_list: List[UserType] = strawberry_django.field()
owner_group: OwnerGroupType = strawberry_django.field()
owner_group_list: List[OwnerGroupType] = 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, Owner, User
from users.models import Group, Owner, OwnerGroup, User
from .filters import *
__all__ = (
'GroupType',
'OwnerGroupType',
'OwnerType',
'UserType',
)
@@ -35,11 +36,21 @@ class UserType(BaseObjectType):
groups: List[GroupType]
@strawberry_django.type(
OwnerGroup,
fields=['id', 'name', 'description'],
filters=OwnerGroupFilter,
pagination=True
)
class OwnerGroupType(BaseObjectType):
pass
@strawberry_django.type(
Owner,
fields=['id', 'name', 'description', 'groups', 'users'],
fields=['id', 'group', 'name', 'description', 'user_groups', 'users'],
filters=OwnerFilter,
pagination=True
)
class OwnerType(BaseObjectType):
pass
group: OwnerGroupType

View File

@@ -1,28 +1,51 @@
import django.db.models.deletion
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='OwnerGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('description', models.CharField(blank=True, max_length=200)),
('name', models.CharField(max_length=100, unique=True)),
],
options={
'verbose_name': 'owner group',
'verbose_name_plural': 'owner groups',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Owner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, unique=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
(
'groups',
'group',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='members',
to='users.ownergroup',
),
),
(
'user_groups',
models.ManyToManyField(
blank=True,
related_name='owners',
related_query_name='owner',
to='users.group',
)
),
),
(
'users',
@@ -31,7 +54,7 @@ class Migration(migrations.Migration):
related_name='owners',
related_query_name='owner',
to=settings.AUTH_USER_MODEL,
)
),
),
],
options={

View File

@@ -7,16 +7,47 @@ from utilities.querysets import RestrictedQuerySet
__all__ = (
'Owner',
'OwnerGroup',
)
class OwnerGroup(AdminModel):
"""
An arbitrary grouping of Owners.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
)
class Meta:
ordering = ['name']
verbose_name = _('owner group')
verbose_name_plural = _('owner groups')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('users:ownergroup', args=[self.pk])
class Owner(AdminModel):
name = models.CharField(
verbose_name=_('name'),
max_length=150,
max_length=100,
unique=True,
)
groups = models.ManyToManyField(
group = models.ForeignKey(
to='users.OwnerGroup',
on_delete=models.PROTECT,
related_name='members',
verbose_name=_('group'),
blank=True,
null=True,
)
user_groups = models.ManyToManyField(
to='users.Group',
verbose_name=_('groups'),
blank=True,
@@ -32,7 +63,7 @@ class Owner(AdminModel):
)
objects = RestrictedQuerySet.as_manager()
clone_fields = ('groups', 'users')
clone_fields = ('user_groups', 'users')
class Meta:
ordering = ('name',)

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, Owner, Token, User
from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
__all__ = (
'GroupTable',
'ObjectPermissionTable',
'OwnerGroupTable',
'OwnerTable',
'TokenTable',
'UserTable',
@@ -146,12 +147,33 @@ class ObjectPermissionTable(NetBoxTable):
)
class OwnerGroupTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
)
class Meta(NetBoxTable.Meta):
model = OwnerGroup
fields = (
'pk', 'id', 'name', 'description',
)
default_columns = ('pk', 'name', 'description')
class OwnerTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
groups = columns.ManyToManyColumn(
group = tables.Column(
verbose_name=_('Group'),
linkify=True,
)
user_groups = columns.ManyToManyColumn(
verbose_name=_('Groups'),
linkify_item=('users:group', {'pk': tables.A('pk')})
)
@@ -166,6 +188,6 @@ class OwnerTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Owner
fields = (
'pk', 'id', 'name', 'description', 'groups', 'users',
'pk', 'id', 'name', 'group', 'description', 'user_groups', 'users',
)
default_columns = ('pk', 'name', 'description', 'groups', 'users')
default_columns = ('pk', 'name', 'group', 'description', 'user_groups', 'users')

View File

@@ -18,6 +18,9 @@ urlpatterns = [
path('permissions/', include(get_model_urls('users', 'objectpermission', detail=False))),
path('permissions/<int:pk>/', include(get_model_urls('users', 'objectpermission'))),
path('owner-groups/', include(get_model_urls('users', 'ownergroup', detail=False))),
path('owner-groups/<int:pk>/', include(get_model_urls('users', 'ownergroup'))),
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 GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import Group, User, ObjectPermission, Owner, Token
from .models import Group, User, ObjectPermission, Owner, OwnerGroup, Token
#
@@ -233,6 +233,67 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
table = tables.ObjectPermissionTable
#
# Owner groups
#
@register_model_view(OwnerGroup, 'list', path='', detail=False)
class OwnerGroupListView(generic.ObjectListView):
queryset = OwnerGroup.objects.all()
filterset = filtersets.OwnerGroupFilterSet
filterset_form = forms.OwnerGroupFilterForm
table = tables.OwnerGroupTable
@register_model_view(OwnerGroup)
class OwnerGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = OwnerGroup.objects.all()
template_name = 'users/ownergroup.html'
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(OwnerGroup, 'add', detail=False)
@register_model_view(OwnerGroup, 'edit')
class OwnerGroupEditView(generic.ObjectEditView):
queryset = OwnerGroup.objects.all()
form = forms.OwnerGroupForm
@register_model_view(OwnerGroup, 'delete')
class OwnerGroupDeleteView(generic.ObjectDeleteView):
queryset = OwnerGroup.objects.all()
@register_model_view(OwnerGroup, 'bulk_import', path='import', detail=False)
class OwnerGroupBulkImportView(generic.BulkImportView):
queryset = OwnerGroup.objects.all()
model_form = forms.OwnerGroupImportForm
@register_model_view(OwnerGroup, 'bulk_edit', path='edit', detail=False)
class OwnerGroupBulkEditView(generic.BulkEditView):
queryset = OwnerGroup.objects.all()
filterset = filtersets.OwnerGroupFilterSet
table = tables.OwnerGroupTable
form = forms.OwnerGroupBulkEditForm
@register_model_view(OwnerGroup, 'bulk_rename', path='rename', detail=False)
class OwnerGroupBulkRenameView(generic.BulkRenameView):
queryset = OwnerGroup.objects.all()
@register_model_view(OwnerGroup, 'bulk_delete', path='delete', detail=False)
class OwnerGroupBulkDeleteView(generic.BulkDeleteView):
queryset = OwnerGroup.objects.all()
filterset = filtersets.OwnerGroupFilterSet
table = tables.OwnerGroupTable
#
# Owners
#