Merge branch 'feature' into 15278-primary-nested-serializers

This commit is contained in:
Jeremy Stretch 2024-03-04 16:42:36 -05:00
commit cd74e040c1
53 changed files with 268 additions and 234 deletions

View File

@ -476,7 +476,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}',
site=site,
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
role=switch_role
)
switch.full_clean()
switch.save()

View File

@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
### Device Role
### Role
The functional [role](./devicerole.md) assigned to this device.
The functional [device role](./devicerole.md) assigned to this device.
### Device Type

View File

@ -13,6 +13,10 @@
The NetBox user interface has been completely refreshed and updated.
#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
The REST API now supports specifying which fields to include in the response data.
### Enhancements
* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
@ -22,6 +26,9 @@ The NetBox user interface has been completely refreshed and updated.
* [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
* [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
* [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
### Other Changes
@ -34,5 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
* [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
* [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

View File

@ -32,11 +32,6 @@ class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = DeviceTypeSerializer(nested=True)
role = DeviceRoleSerializer(nested=True)
device_role = DeviceRoleSerializer(
nested=True,
read_only=True,
help_text='Deprecated in v3.6 in favor of `role`.'
)
tenant = TenantSerializer(
nested=True,
required=False,
@ -83,13 +78,13 @@ class DeviceSerializer(NetBoxModelSerializer):
class Meta:
model = Device
fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
'module_bay_count', 'inventory_item_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@ -104,22 +99,19 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data
def get_device_role(self, obj):
return obj.role
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True)
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -815,20 +815,6 @@ class Device(
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@property
def device_role(self):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
return self.role
@device_role.setter
def device_role(self, value):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
self.role = value
def clean(self):
super().clean()

View File

@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
device = Device.objects.create(
site=self.site,
device_type=self.device.device_type,
device_role=self.device.device_role,
role=self.device.role,
name='Test mid-span Device'
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')

View File

@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
def test_old_device_role_field(self):
"""
Ensure that the old device role field sets the value in the new role field.
"""
# Test getter method
device = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name='Test Device 1',
device_role=DeviceRole.objects.first()
)
device.full_clean()
device.save()
self.assertEqual(device.role, device.device_role)
# Test setter method
device.device_role = DeviceRole.objects.last()
device.full_clean()
device.save()
self.assertEqual(device.role, device.device_role)
class CableTestCase(TestCase):

View File

@ -4,13 +4,13 @@ from collections import defaultdict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group, AnonymousUser
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
from users.models import ObjectPermission
from users.models import Group, ObjectPermission
from utilities.permissions import (
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
)

View File

@ -392,19 +392,19 @@ ADMIN_MENU = Menu(
),
# Proxy model for auth.Group
MenuItem(
link=f'users:netboxgroup_list',
link=f'users:group_list',
link_text=_('Groups'),
permissions=[f'auth.view_group'],
staff_only=True,
buttons=(
MenuItemButton(
link=f'users:netboxgroup_add',
link=f'users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group']
),
MenuItemButton(
link=f'users:netboxgroup_import',
link=f'users:group_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_group']

View File

@ -2,7 +2,6 @@ import datetime
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.test import Client
from django.test.utils import override_settings
@ -12,7 +11,7 @@ from rest_framework.test import APIClient
from dcim.models import Site
from ipam.models import Prefix
from users.models import ObjectPermission, Token
from users.models import Group, ObjectPermission, Token
from utilities.testing import TestCase
from utilities.testing.api import APITestCase

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 B

View File

@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
const value = link.getAttribute('data-value');
//@ts-ignore
target.slim.setData([
{text: label, value: value}
]);
const change = new Event('change');
target.dispatchEvent(change);
target.tomselect.addOption({
id: value,
display: label,
});
//@ts-ignore
target.tomselect.addItem(value);
}

View File

@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
// Initialize <select> elements which are populated via a REST API call
export function initDynamicSelects(): void {
for (const select of getElements<HTMLSelectElement>('select.api-select')) {
for (const select of getElements<HTMLSelectElement>('select.api-select:not(.tomselected)')) {
new DynamicTomSelect(select, {
...config,
valueField: VALUE_FIELD,

View File

@ -7,7 +7,7 @@ import { getElements } from '../util';
// Initialize <select> elements with statically-defined options
export function initStaticSelects(): void {
for (const select of getElements<HTMLSelectElement>(
'select:not(.api-select):not(.color-select)',
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
)) {
new TomSelect(select, {
...config,
@ -24,7 +24,7 @@ export function initColorSelects(): void {
)}"></span> ${escape(item.text)}</div>`;
}
for (const select of getElements<HTMLSelectElement>('select.color-select')) {
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
new TomSelect(select, {
...config,
maxOptions: undefined,

View File

@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
return selected;
}
/**
* Get data that can only be accessed via Django context, and is thus already rendered in the HTML
* template.
*
* @see Templates requiring Django context data have a `{% block data %}` block.
*
* @param key Property name, which must exist on the HTML element. If not already prefixed with
* `data-`, `data-` will be prepended to the property.
* @returns Value if it exists, `null` if not.
*/
export function getNetboxData(key: string): string | null {
if (!key.startsWith('data-')) {
key = `data-${key}`;
}
for (const element of getElements('body > div#netbox-data > *')) {
const value = element.getAttribute(key);
if (isTruthy(value)) {
return value;
}
}
return null;
}
/**
* Toggle visibility of an element.
*/

View File

@ -28,6 +28,13 @@
}
// Remove the bottom margin of <p> elements inside a table cell
td > .rendered-markdown {
p:last-of-type {
margin-bottom: 0;
}
}
// Markdown preview
.markdown-widget {
.preview {

View File

@ -2,7 +2,7 @@
// Color labels
span.color-label {
display: block;
display: inline-block;
width: 5rem;
height: 1rem;
padding: $badge-padding-y $badge-padding-x;

View File

@ -9,6 +9,10 @@ pre {
// Tabler sets display: flex
display: inline-block;
}
.btn-sm {
// $border-radius-sm (2px) is too small
border-radius: $border-radius;
}
// Tabs
.nav-tabs {

View File

@ -23,7 +23,6 @@ table.attr-table {
// Restyle row header
th {
color: $gray-700;
font-weight: normal;
width: min-content;
}

View File

@ -70,10 +70,5 @@
{# User messages #}
{% include 'inc/messages.html' %}
{# Data container #}
<div id="netbox-data" style="display: none!important; visibility: hidden!important">
{% block data %}{% endblock %}
</div>
</body>
</html>

View File

@ -163,7 +163,7 @@
</div>
<div class="col col-12 col-xl-7">
<div class="text-end mb-4">
<select class="btn btn-outline-dark rack-view">
<select class="btn btn-outline-secondary no-ts rack-view">
<option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
<option value="images-only">{% trans "Images only" %}</option>
<option value="labels-only">{% trans "Labels only" %}</option>

View File

@ -11,13 +11,11 @@
<a href="{% url 'dcim:rack_list' %}{% querystring request %}" class="btn btn-primary">
<i class="mdi mdi-format-list-checkbox"></i> {% trans "View List" %}
</a>
<div class="btn-group" role="group">
<select class="btn btn-outline-secondary rack-view">
<select class="btn btn-outline-secondary no-ts rack-view">
<option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
<option value="images-only">{% trans "Images only" %}</option>
<option value="labels-only">{% trans "Labels only" %}</option>
</select>
</div>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">{% trans "Front" %}</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">{% trans "Rear" %}</a>

View File

@ -1,6 +1,6 @@
{% load helpers %}
<div class="toast shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
<div class="toast toast-dark border-0 shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
<div class="toast-header text-bg-{{ status }}">
<i class="mdi mdi-{{ status|icon_from_status }} me-1"></i>
{{ title }}

View File

@ -24,7 +24,7 @@
<div class="card">
<h5 class="card-header">{% trans "Users" %}</h5>
<div class="list-group list-group-flush">
{% for user in object.user_set.all %}
{% 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>

View File

@ -82,7 +82,7 @@
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
<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 %}

View File

@ -53,7 +53,7 @@
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:netboxgroup' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
<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 %}

View File

@ -1,5 +1,4 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
@ -7,7 +6,7 @@ from rest_framework import serializers
from netbox.api.fields import ContentTypeField
from netbox.api.serializers import WritableNestedSerializer
from users.models import ObjectPermission, Token
from users.models import Group, ObjectPermission, Token
__all__ = [
'NestedGroupSerializer',

View File

@ -1,11 +1,10 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
from users.models import ObjectPermission
from users.models import Group, ObjectPermission
from .users import GroupSerializer, UserSerializer
__all__ = (

View File

@ -1,11 +1,11 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
from users.models import Group
__all__ = (
'GroupSerializer',

View File

@ -1,11 +1,9 @@
import logging
from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db.models import Count
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework.exceptions import AuthenticationFailed
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import APIRootView
@ -15,7 +13,7 @@ from rest_framework.viewsets import ViewSet
from netbox.api.viewsets import NetBoxModelViewSet
from users import filtersets
from users.models import ObjectPermission, Token, UserConfig
from users.models import Group, ObjectPermission, Token, UserConfig
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge
from . import serializers
@ -40,7 +38,7 @@ class UserViewSet(NetBoxModelViewSet):
class GroupViewSet(NetBoxModelViewSet):
queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name')
queryset = Group.objects.annotate(user_count=Count('user'))
serializer_class = serializers.GroupSerializer
filterset_class = filtersets.GroupFilterSet

View File

@ -1,11 +1,10 @@
import django_filters
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet
from users.models import ObjectPermission, Token
from users.models import Group, ObjectPermission, Token
__all__ = (
'GroupFilterSet',

View File

@ -14,7 +14,7 @@ __all__ = (
class GroupImportForm(CSVModelForm):
class Meta:
model = NetBoxGroup
model = Group
fields = (
'name',
)

View File

@ -1,11 +1,10 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from users.models import NetBoxGroup, User, ObjectPermission, Token
from users.models import Group, ObjectPermission, Token, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.widgets import DateTimePicker
@ -19,7 +18,7 @@ __all__ = (
class GroupFilterForm(NetBoxModelFilterSetForm):
model = NetBoxGroup
model = Group
fieldsets = (
(None, ('q', 'filter_id',)),
)

View File

@ -1,7 +1,6 @@
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import FieldError
@ -253,7 +252,7 @@ class GroupForm(forms.ModelForm):
)
class Meta:
model = NetBoxGroup
model = Group
fields = [
'name', 'users', 'object_permissions',
]
@ -263,14 +262,14 @@ class GroupForm(forms.ModelForm):
# Populate assigned users and permissions
if self.instance.pk:
self.fields['users'].initial = self.instance.user_set.values_list('id', flat=True)
self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
self.fields['object_permissions'].initial = self.instance.object_permissions.values_list('id', flat=True)
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Update assigned users and permissions
instance.user_set.set(self.cleaned_data['users'])
instance.users.set(self.cleaned_data['users'])
instance.object_permissions.set(self.cleaned_data['object_permissions'])
return instance

View File

@ -1,10 +1,10 @@
import graphene
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
from users.models import Group
from utilities.graphql_optimizer import gql_query_optimizer
from .types import *
class UsersQuery(graphene.ObjectType):

View File

@ -1,8 +1,8 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from graphene_django import DjangoObjectType
from users import filtersets
from users.models import Group
from utilities.querysets import RestrictedQuerySet
__all__ = (

View File

@ -1,5 +1,3 @@
# Generated by Django 5.0.1 on 2024-01-31 23:18
from django.db import migrations
@ -27,12 +25,26 @@ class Migration(migrations.Migration):
]
operations = [
# 0001_squashed had model with db_table=auth_user - now we switch it
# to None to use the default Django resolution (users.user)
# The User table was originally created as 'auth_user'. Now we nullify the model's
# db_table option, so that it defaults to the app & model name (users_user). This
# causes the database table to be renamed.
migrations.AlterModelTable(
name='user',
table=None,
),
# Rename auth_user_* sequences
migrations.RunSQL("ALTER TABLE auth_user_groups_id_seq RENAME TO users_user_groups_id_seq"),
migrations.RunSQL("ALTER TABLE auth_user_id_seq RENAME TO users_user_id_seq"),
migrations.RunSQL("ALTER TABLE auth_user_user_permissions_id_seq RENAME TO users_user_user_permissions_id_seq"),
# Rename auth_user_* indexes
migrations.RunSQL("ALTER INDEX auth_user_pkey RENAME TO users_user_pkey"),
# Hash is deterministic; generated via schema_editor._create_index_name()
migrations.RunSQL("ALTER INDEX auth_user_username_6821ab7c_like RENAME TO users_user_username_06e46fe6_like"),
migrations.RunSQL("ALTER INDEX auth_user_username_key RENAME TO users_user_username_key"),
# Update ContentTypes
migrations.RunPython(
code=update_content_types,
reverse_code=migrations.RunPython.noop

View File

@ -0,0 +1,80 @@
import users.models
from django.db import migrations, models
def update_custom_fields(apps, schema_editor):
"""
Update any CustomFields referencing the old Group model to use the new model.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
CustomField = apps.get_model('extras', 'CustomField')
Group = apps.get_model('users', 'Group')
if old_ct := ContentType.objects.filter(app_label='users', model='netboxgroup').first():
new_ct = ContentType.objects.get_for_model(Group)
CustomField.objects.filter(object_type=old_ct).update(object_type=new_ct)
class Migration(migrations.Migration):
dependencies = [
('users', '0005_alter_user_table'),
]
operations = [
# Create the new Group model & table
migrations.CreateModel(
name='Group',
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)),
('permissions', models.ManyToManyField(blank=True, related_name='groups', related_query_name='group', to='auth.permission')),
],
options={
'verbose_name': 'group',
'verbose_name_plural': 'groups',
},
managers=[
('objects', users.models.NetBoxGroupManager()),
],
),
# Copy existing groups from the old table into the new one
migrations.RunSQL(
"INSERT INTO users_group (SELECT id, name, '' AS description FROM auth_group)"
),
# Update the sequence for group ID values
migrations.RunSQL(
"SELECT setval('users_group_id_seq', (SELECT MAX(id) FROM users_group))"
),
# Update the "groups" M2M fields on User & ObjectPermission
migrations.AlterField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, related_name='users', related_query_name='user', to='users.group'),
),
migrations.AlterField(
model_name='objectpermission',
name='groups',
field=models.ManyToManyField(blank=True, related_name='object_permissions', to='users.group'),
),
# Delete groups from the old table
migrations.RunSQL(
"DELETE from auth_group"
),
# Update custom fields
migrations.RunPython(
code=update_custom_fields,
reverse_code=migrations.RunPython.noop
),
# Delete the proxy model
migrations.DeleteModel(
name='NetBoxGroup',
),
]

View File

@ -4,7 +4,12 @@ import os
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import (
AbstractUser, Group, GroupManager, User as DjangoUser, UserManager as DjangoUserManager
AbstractUser,
Group as DjangoGroup,
GroupManager,
Permission,
User as DjangoUser,
UserManager as DjangoUserManager
)
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
@ -25,7 +30,7 @@ from utilities.utils import flatten_dict
from .constants import *
__all__ = (
'NetBoxGroup',
'Group',
'ObjectPermission',
'Token',
'User',
@ -33,22 +38,61 @@ __all__ = (
)
#
# Proxies for Django's User and Group models
#
class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
pass
class Group(models.Model):
name = models.CharField(
verbose_name=_('name'),
max_length=150,
unique=True
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
# Replicate legacy Django permissions support from stock Group model
# to ensure authentication backend compatibility
permissions = models.ManyToManyField(
Permission,
verbose_name=_("permissions"),
blank=True,
related_name='groups',
related_query_name='group'
)
objects = NetBoxGroupManager()
class Meta:
verbose_name = _('group')
verbose_name_plural = _('groups')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('users:group', args=[self.pk])
def natural_key(self):
return (self.name,)
class UserManager(DjangoUserManager.from_queryset(RestrictedQuerySet)):
pass
class NetBoxGroupManager(GroupManager.from_queryset(RestrictedQuerySet)):
pass
class User(AbstractUser):
"""
Proxy contrib.auth.models.User for the UI
"""
groups = models.ManyToManyField(
to='users.Group',
verbose_name=_('groups'),
blank=True,
related_name='users',
related_query_name='user'
)
objects = UserManager()
class Meta:
@ -68,22 +112,6 @@ class User(AbstractUser):
raise ValidationError(_("A user with this username already exists."))
class NetBoxGroup(Group):
"""
Proxy contrib.auth.models.User for the UI
"""
objects = NetBoxGroupManager()
class Meta:
proxy = True
ordering = ('name',)
verbose_name = _('group')
verbose_name_plural = _('groups')
def get_absolute_url(self):
return reverse('users:netboxgroup', args=[self.pk])
#
# User preferences
#
@ -360,7 +388,7 @@ class ObjectPermission(models.Model):
related_name='object_permissions'
)
groups = models.ManyToManyField(
to=Group,
to='users.Group',
blank=True,
related_name='object_permissions'
)

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from account.tables import UserTokenTable
from netbox.tables import NetBoxTable, columns
from users.models import NetBoxGroup, User, ObjectPermission, Token
from users.models import Group, ObjectPermission, Token, User
__all__ = (
'GroupTable',
@ -33,7 +33,7 @@ class UserTable(NetBoxTable):
)
groups = columns.ManyToManyColumn(
verbose_name=_('Groups'),
linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
linkify_item=('users:group', {'pk': tables.A('pk')})
)
is_active = columns.BooleanColumn(
verbose_name=_('Is Active'),
@ -67,7 +67,7 @@ class GroupTable(NetBoxTable):
)
class Meta(NetBoxTable.Meta):
model = NetBoxGroup
model = Group
fields = (
'pk', 'id', 'name', 'users_count',
)
@ -107,7 +107,7 @@ class ObjectPermissionTable(NetBoxTable):
)
groups = columns.ManyToManyColumn(
verbose_name=_('Groups'),
linkify_item=('users:netboxgroup', {'pk': tables.A('pk')})
linkify_item=('users:group', {'pk': tables.A('pk')})
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),

View File

@ -1,9 +1,8 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from users.models import ObjectPermission, Token
from users.models import Group, ObjectPermission, Token
from utilities.testing import APIViewTestCases, APITestCase, create_test_user
from utilities.utils import deepmerge

View File

@ -1,13 +1,12 @@
import datetime
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.utils.timezone import make_aware
from users import filtersets
from users.models import ObjectPermission, Token
from users.models import Group, ObjectPermission, Token
from utilities.testing import BaseFilterSetTests
User = get_user_model()

View File

@ -1,4 +1,3 @@
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from users.models import *
@ -70,7 +69,7 @@ class GroupTestCase(
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = NetBoxGroup
model = Group
maxDiff = None
@classmethod

View File

@ -23,11 +23,11 @@ urlpatterns = [
path('users/<int:pk>/', include(get_model_urls('users', 'user'))),
# Groups
path('groups/', views.GroupListView.as_view(), name='netboxgroup_list'),
path('groups/add/', views.GroupEditView.as_view(), name='netboxgroup_add'),
path('groups/import/', views.GroupBulkImportView.as_view(), name='netboxgroup_import'),
path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='netboxgroup_bulk_delete'),
path('groups/<int:pk>/', include(get_model_urls('users', 'netboxgroup'))),
path('groups/', views.GroupListView.as_view(), name='group_list'),
path('groups/add/', views.GroupEditView.as_view(), name='group_add'),
path('groups/import/', views.GroupBulkImportView.as_view(), name='group_import'),
path('groups/delete/', views.GroupBulkDeleteView.as_view(), name='group_bulk_delete'),
path('groups/<int:pk>/', include(get_model_urls('users', 'group'))),
# Permissions
path('permissions/', views.ObjectPermissionListView.as_view(), name='objectpermission_list'),

View File

@ -5,7 +5,7 @@ from extras.tables import ObjectChangeTable
from netbox.views import generic
from utilities.views import register_model_view
from . import filtersets, forms, tables
from .models import NetBoxGroup, User, ObjectPermission, Token
from .models import Group, User, ObjectPermission, Token
#
@ -110,36 +110,36 @@ class UserBulkDeleteView(generic.BulkDeleteView):
#
class GroupListView(generic.ObjectListView):
queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
queryset = Group.objects.annotate(users_count=Count('user'))
filterset = filtersets.GroupFilterSet
filterset_form = forms.GroupFilterForm
table = tables.GroupTable
@register_model_view(NetBoxGroup)
@register_model_view(Group)
class GroupView(generic.ObjectView):
queryset = NetBoxGroup.objects.all()
queryset = Group.objects.all()
template_name = 'users/group.html'
@register_model_view(NetBoxGroup, 'edit')
@register_model_view(Group, 'edit')
class GroupEditView(generic.ObjectEditView):
queryset = NetBoxGroup.objects.all()
queryset = Group.objects.all()
form = forms.GroupForm
@register_model_view(NetBoxGroup, 'delete')
@register_model_view(Group, 'delete')
class GroupDeleteView(generic.ObjectDeleteView):
queryset = NetBoxGroup.objects.all()
queryset = Group.objects.all()
class GroupBulkImportView(generic.BulkImportView):
queryset = NetBoxGroup.objects.all()
queryset = Group.objects.all()
model_form = forms.GroupImportForm
class GroupBulkDeleteView(generic.BulkDeleteView):
queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
queryset = Group.objects.annotate(users_count=Count('user'))
filterset = filtersets.GroupFilterSet
table = tables.GroupTable

View File

@ -34,23 +34,13 @@ def get_serializer_for_model(model, prefix=''):
"""
Dynamically resolve and return the appropriate serializer for a model.
"""
app_name, model_name = model._meta.label.split('.')
# Serializers for Django's auth models are in the users app
if app_name == 'auth':
app_name = 'users'
# Account for changes using Proxy model
if app_name == 'users':
if model_name == 'NetBoxUser':
model_name = 'User'
elif model_name == 'NetBoxGroup':
model_name = 'Group'
serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer'
app_label, model_name = model._meta.label.split('.')
serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
try:
return dynamic_import(serializer_name)
except AttributeError:
raise SerializerNotFound(
f"Could not determine serializer for {app_name}.{model_name} with prefix '{prefix}'"
f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'"
)

View File

@ -36,7 +36,7 @@
{% elif 'data-clipboard' in field.field.widget.attrs %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy"></i>
</button>
</div>

View File

@ -19,7 +19,7 @@
<div class="dropdown-menu-columns">
<div class="dropdown-menu-column pb-2">
{% for group, items in groups %}
<div class="text-uppercase fw-bold fs-5 ps-3 pt-3 pb-1">
<div class="text-uppercase text-secondary fw-bold fs-5 ps-3 pt-3 pb-1">
{{ group.label }}
</div>
{% for item, buttons in items %}

View File

@ -1,6 +1,8 @@
<div class="input-group">
{% include 'django/forms/widgets/number.html' %}
<button type="button" class="btn btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown"></button>
<button type="button" class="btn" data-bs-toggle="dropdown">
<i class="mdi mdi-chevron-down"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for value, label in widget.options %}
<li>

View File

@ -1,11 +1,12 @@
import datetime
import decimal
import json
import nh3
import re
from decimal import Decimal
from itertools import count, groupby
from urllib.parse import urlencode
import nh3
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
@ -23,7 +24,6 @@ from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
from extras.utils import is_taggable
from netbox.config import get_config
from netbox.plugins import PluginConfig
from urllib.parse import urlencode
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
from .constants import HTML_ALLOWED_ATTRIBUTES, HTML_ALLOWED_TAGS
@ -48,26 +48,16 @@ def get_viewname(model, action=None, rest_api=False):
model_name = model._meta.model_name
if rest_api:
if is_plugin:
viewname = f'plugins-api:{app_label}-api:{model_name}'
else:
# Alter the app_label for group and user model_name to point to users app
if app_label == 'auth' and model_name in ['group', 'user']:
app_label = 'users'
if app_label == 'users' and model._meta.proxy and model_name in ['netboxuser', 'netboxgroup']:
model_name = model._meta.proxy_for_model._meta.model_name
viewname = f'{app_label}-api:{model_name}'
# Append the action, if any
if is_plugin:
viewname = f'plugins-api:{viewname}'
if action:
viewname = f'{viewname}-{action}'
else:
viewname = f'{app_label}:{model_name}'
# Prepend the plugins namespace if this is a plugin model
if is_plugin:
viewname = f'plugins:{viewname}'
# Append the action, if any
if action:
viewname = f'{viewname}_{action}'