Merge branch 'feature' into oob_ip

This commit is contained in:
Jeremy Stretch 2023-07-25 13:49:15 -04:00 committed by GitHub
commit e38ba1283e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1107 additions and 204 deletions

View File

@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`.
## Stores ## Stores
### `counter_fields`
A dictionary mapping of models to foreign keys with which cached counter fields are associated.
### `data_backends` ### `data_backends`
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md). A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).

View File

@ -670,14 +670,28 @@ class DeviceSerializer(NetBoxModelSerializer):
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
# Counter fields
console_port_count = serializers.IntegerField(read_only=True)
console_server_port_count = serializers.IntegerField(read_only=True)
power_port_count = serializers.IntegerField(read_only=True)
power_outlet_count = serializers.IntegerField(read_only=True)
interface_count = serializers.IntegerField(read_only=True)
front_port_count = serializers.IntegerField(read_only=True)
rear_port_count = serializers.IntegerField(read_only=True)
device_bay_count = serializers.IntegerField(read_only=True)
module_bay_count = serializers.IntegerField(read_only=True)
inventory_item_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'oob_ip', 'last_updated', '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(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
@ -699,9 +713,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'oob_ip', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
'created', 'last_updated', '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)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -9,7 +9,8 @@ class DCIMConfig(AppConfig):
def ready(self): def ready(self):
from . import signals, search from . import signals, search
from .models import CableTermination from .models import CableTermination, Device
from utilities.counters import connect_counters
# Register denormalized fields # Register denormalized fields
denormalized.register(CableTermination, '_device', { denormalized.register(CableTermination, '_device', {
@ -24,3 +25,6 @@ class DCIMConfig(AppConfig):
denormalized.register(CableTermination, '_location', { denormalized.register(CableTermination, '_location', {
'_site': 'site', '_site': 'site',
}) })
# Register counters
connect_counters(Device)

View File

@ -0,0 +1,100 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
devices = list(Device.objects.all().annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
_power_outlet_count=Count('poweroutlets', distinct=True),
_interface_count=Count('interfaces', distinct=True),
_front_port_count=Count('frontports', distinct=True),
_rear_port_count=Count('rearports', distinct=True),
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
))
for device in devices:
device.console_port_count = device._console_port_count
device.console_server_port_count = device._console_server_port_count
device.power_port_count = device._power_port_count
device.power_outlet_count = device._power_outlet_count
device.interface_count = device._interface_count
device.front_port_count = device._front_port_count
device.rear_port_count = device._rear_port_count
device.device_bay_count = device._device_bay_count
device.module_bay_count = device._module_bay_count
device.inventory_item_count = device._inventory_item_count
Device.objects.bulk_update(devices, [
'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',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0174_rack_starting_unit'),
]
operations = [
migrations.AddField(
model_name='device',
name='console_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'),
),
migrations.AddField(
model_name='device',
name='console_server_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'),
),
migrations.AddField(
model_name='device',
name='power_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'),
),
migrations.AddField(
model_name='device',
name='power_outlet_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'),
),
migrations.AddField(
model_name='device',
name='interface_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'),
),
migrations.AddField(
model_name='device',
name='front_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'),
),
migrations.AddField(
model_name='device',
name='rear_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'),
),
migrations.AddField(
model_name='device',
name='device_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'),
),
migrations.AddField(
model_name='device',
name='module_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'),
),
migrations.AddField(
model_name='device',
name='inventory_item_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'),
),
migrations.RunPython(
recalculate_device_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from wireless.choices import * from wireless.choices import *
from wireless.utils import get_channel_attr from wireless.utils import get_channel_attr
@ -269,7 +270,7 @@ class PathEndpoint(models.Model):
# Console components # Console components
# #
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
@ -292,7 +293,7 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
@ -319,7 +320,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components # Power components
# #
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
@ -428,7 +429,7 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
} }
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -537,7 +538,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count() return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint): class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
""" """
@ -888,7 +889,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Pass-through ports # Pass-through ports
# #
class FrontPort(ModularComponentModel, CabledObjectModel): class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
""" """
@ -949,7 +950,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
}) })
class RearPort(ModularComponentModel, CabledObjectModel): class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
""" """
@ -990,7 +991,7 @@ class RearPort(ModularComponentModel, CabledObjectModel):
# Bays # Bays
# #
class ModuleBay(ComponentModel): class ModuleBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
@ -1006,7 +1007,7 @@ class ModuleBay(ComponentModel):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
@ -1064,7 +1065,7 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk]) return reverse('dcim:inventoryitemrole', args=[self.pk])
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes. InventoryItems are used only for inventory purposes.

View File

@ -21,7 +21,7 @@ from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
from .device_components import * from .device_components import *
from .mixins import WeightMixin from .mixins import WeightMixin
@ -647,6 +647,48 @@ class Device(PrimaryModel, ConfigContextModel):
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
) )
# Counter fields
console_port_count = CounterCacheField(
to_model='dcim.ConsolePort',
to_field='device'
)
console_server_port_count = CounterCacheField(
to_model='dcim.ConsoleServerPort',
to_field='device'
)
power_port_count = CounterCacheField(
to_model='dcim.PowerPort',
to_field='device'
)
power_outlet_count = CounterCacheField(
to_model='dcim.PowerOutlet',
to_field='device'
)
interface_count = CounterCacheField(
to_model='dcim.Interface',
to_field='device'
)
front_port_count = CounterCacheField(
to_model='dcim.FrontPort',
to_field='device'
)
rear_port_count = CounterCacheField(
to_model='dcim.RearPort',
to_field='device'
)
device_bay_count = CounterCacheField(
to_model='dcim.DeviceBay',
to_field='device'
)
module_bay_count = CounterCacheField(
to_model='dcim.ModuleBay',
to_field='device'
)
inventory_item_count = CounterCacheField(
to_model='dcim.InventoryItem',
to_field='device'
)
# Generic relations # Generic relations
contacts = GenericRelation( contacts = GenericRelation(
to='tenancy.ContactAssignment' to='tenancy.ContactAssignment'

View File

@ -1,10 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from dcim import models
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from django.utils.translation import gettext as _
from dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
@ -234,6 +234,36 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:device_list' url_name='dcim:device_list'
) )
console_port_count = tables.Column(
verbose_name=_('Console ports')
)
console_server_port_count = tables.Column(
verbose_name=_('Console server ports')
)
power_port_count = tables.Column(
verbose_name=_('Power ports')
)
power_outlet_count = tables.Column(
verbose_name=_('Power outlets')
)
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
front_port_count = tables.Column(
verbose_name=_('Front ports')
)
rear_port_count = tables.Column(
verbose_name=_('Rear ports')
)
device_bay_count = tables.Column(
verbose_name=_('Device bays')
)
module_bay_count = tables.Column(
verbose_name=_('Module bays')
)
inventory_item_count = tables.Column(
verbose_name=_('Inventory items')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Device model = models.Device

View File

@ -1876,7 +1876,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
template_name = 'dcim/device/consoleports.html', template_name = 'dcim/device/consoleports.html',
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.consoleports.count(), badge=lambda obj: obj.console_port_count,
permission='dcim.view_consoleport', permission='dcim.view_consoleport',
weight=550, weight=550,
hide_if_empty=True hide_if_empty=True
@ -1891,7 +1891,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
template_name = 'dcim/device/consoleserverports.html' template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverports.count(), badge=lambda obj: obj.console_server_port_count,
permission='dcim.view_consoleserverport', permission='dcim.view_consoleserverport',
weight=560, weight=560,
hide_if_empty=True hide_if_empty=True
@ -1906,7 +1906,7 @@ class DevicePowerPortsView(DeviceComponentsView):
template_name = 'dcim/device/powerports.html' template_name = 'dcim/device/powerports.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.powerports.count(), badge=lambda obj: obj.power_port_count,
permission='dcim.view_powerport', permission='dcim.view_powerport',
weight=570, weight=570,
hide_if_empty=True hide_if_empty=True
@ -1921,7 +1921,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
template_name = 'dcim/device/poweroutlets.html' template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlets.count(), badge=lambda obj: obj.power_outlet_count,
permission='dcim.view_poweroutlet', permission='dcim.view_poweroutlet',
weight=580, weight=580,
hide_if_empty=True hide_if_empty=True
@ -1957,7 +1957,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
template_name = 'dcim/device/frontports.html' template_name = 'dcim/device/frontports.html'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.frontports.count(), badge=lambda obj: obj.front_port_count,
permission='dcim.view_frontport', permission='dcim.view_frontport',
weight=530, weight=530,
hide_if_empty=True hide_if_empty=True
@ -1972,7 +1972,7 @@ class DeviceRearPortsView(DeviceComponentsView):
template_name = 'dcim/device/rearports.html' template_name = 'dcim/device/rearports.html'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rearports.count(), badge=lambda obj: obj.rear_port_count,
permission='dcim.view_rearport', permission='dcim.view_rearport',
weight=540, weight=540,
hide_if_empty=True hide_if_empty=True
@ -1987,7 +1987,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.modulebays.count(), badge=lambda obj: obj.module_bay_count,
permission='dcim.view_modulebay', permission='dcim.view_modulebay',
weight=510, weight=510,
hide_if_empty=True hide_if_empty=True
@ -2002,7 +2002,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.devicebays.count(), badge=lambda obj: obj.device_bay_count,
permission='dcim.view_devicebay', permission='dcim.view_devicebay',
weight=500, weight=500,
hide_if_empty=True hide_if_empty=True
@ -2017,7 +2017,7 @@ class DeviceInventoryView(DeviceComponentsView):
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitems.count(), badge=lambda obj: obj.inventory_item_count,
permission='dcim.view_inventoryitem', permission='dcim.view_inventoryitem',
weight=590, weight=590,
hide_if_empty=True hide_if_empty=True

View File

@ -8,6 +8,7 @@ from netbox.models.features import *
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
__all__ = ( __all__ = (
'ChangeLoggedModel', 'ChangeLoggedModel',
'NestedGroupModel', 'NestedGroupModel',

View File

@ -353,7 +353,7 @@ ADMIN_MENU = Menu(
icon_class='mdi mdi-account-multiple', icon_class='mdi mdi-account-multiple',
groups=( groups=(
MenuGroup( MenuGroup(
label=_('Users'), label=_('Authentication'),
items=( items=(
# Proxy model for auth.User # Proxy model for auth.User
MenuItem( MenuItem(
@ -399,6 +399,7 @@ ADMIN_MENU = Menu(
) )
) )
), ),
get_model_item('users', 'token', _('API Tokens')),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
), ),
), ),

View File

@ -21,6 +21,7 @@ class Registry(dict):
# Initialize the global registry # Initialize the global registry
registry = Registry({ registry = Registry({
'counter_fields': collections.defaultdict(dict),
'data_backends': dict(), 'data_backends': dict(),
'denormalized_fields': collections.defaultdict(list), 'denormalized_fields': collections.defaultdict(list),
'model_features': dict(), 'model_features': dict(),

View File

@ -469,6 +469,7 @@ EXEMPT_EXCLUDE_MODELS = (
('auth', 'group'), ('auth', 'group'),
('auth', 'user'), ('auth', 'user'),
('users', 'objectpermission'), ('users', 'objectpermission'),
('users', 'token'),
) )
# All URLs starting with a string listed here are exempt from login enforcement # All URLs starting with a string listed here are exempt from login enforcement

View File

@ -34,7 +34,7 @@
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:token_list' %}"> <a class="dropdown-item" href="{% url 'users:usertoken_list' %}">
<i class="mdi mdi-key"></i> API Tokens <i class="mdi mdi-key"></i> API Tokens
</a> </a>
</li> </li>

View File

@ -1,58 +0,0 @@
{% extends 'generic/object.html' %}
{% load form_helpers %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">Token</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Key</th>
<td>
<div class="float-end">
{% copy_content "token_id" %}
</div>
<div id="token_id">{{ key }}</div>
</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">User</th>
<td>{{ object.user }}</td>
</tr>
<tr>
<th scope="row">Created</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">Expires</th>
<td>
{% if object.expires %}
{{ object.expires|annotated_date }}
{% else %}
<span>Never</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col col-md-12 text-center">
<a href="{% url 'users:token_add' %}" class="btn btn-outline-primary">Add Another</a>
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -18,7 +18,7 @@
</li> </li>
{% endif %} {% endif %}
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:token_list' %}">{% trans "API Tokens" %}</a> <a class="nav-link{% if active_tab == 'api-tokens' %} active{% endif %}" href="{% url 'users:usertoken_list' %}">{% trans "API Tokens" %}</a>
</li> </li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,69 @@
{% extends 'generic/object.html' %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
{% load plugins %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'users:usertoken_list' %}">{% trans "My API Tokens" %}</a></li>
{% endblock breadcrumbs %}
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Key" %}</th>
<td>
{% if key %}
<div class="float-end">
{% copy_content "token_id" %}
</div>
<div id="token_id">{{ key }}</div>
{% else %}
{{ object.partial }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Write enabled" %}</th>
<td>{% checkmark object.write_enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Allowed IPs" %}</th>
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -2,12 +2,12 @@
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block title %}API Tokens{% endblock %} {% block title %}My API Tokens{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
<a href="{% url 'users:token_add' %}" class="btn btn-sm btn-primary my-3"> <a href="{% url 'users:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a Token <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a Token
</a> </a>
</div> </div>

View File

@ -0,0 +1,56 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Token" %} {{ object }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Key" %}</th>
<td>{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "User" %}</th>
<td>
<a href="{% url 'users:netboxuser' pk=object.user.pk %}">{{ object.user }}</a>
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Write enabled" %}</th>
<td>{% checkmark object.write_enabled %}</td>
</tr>
<tr>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expires" %}</th>
<td>{{ object.expires|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last used" %}</th>
<td>{{ object.last_used|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Allowed IPs" %}</th>
<td>{{ object.allowed_ips|join:", "|placeholder }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,11 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from users.models import ObjectPermission, Token
from . import filters, forms, inlines
# #
# Users & groups # Users & groups
# #
@ -13,19 +8,3 @@ from . import filters, forms, inlines
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
admin.site.unregister(Group) admin.site.unregister(Group)
admin.site.unregister(User) admin.site.unregister(User)
#
# REST API tokens
#
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
form = forms.TokenAdminForm
list_display = [
'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips'
]
def list_allowed_ips(self, obj):
return obj.allowed_ips or 'Any'
list_allowed_ips.short_description = "Allowed IPs"

View File

@ -1,21 +0,0 @@
from django import forms
from django.utils.translation import gettext as _
from users.models import Token
__all__ = (
'TokenAdminForm',
)
class TokenAdminForm(forms.ModelForm):
key = forms.CharField(
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
class Meta:
fields = [
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
]
model = Token

View File

@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
__all__ = ( __all__ = (
'GroupFilterSet', 'GroupFilterSet',
'ObjectPermissionFilterSet', 'ObjectPermissionFilterSet',
'TokenFilterSet',
'UserFilterSet', 'UserFilterSet',
) )

View File

@ -1,13 +1,17 @@
from django import forms from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from users.models import * from users.models import *
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin, BulkEditForm
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker
__all__ = ( __all__ = (
'ObjectPermissionBulkEditForm', 'ObjectPermissionBulkEditForm',
'UserBulkEditForm', 'UserBulkEditForm',
'TokenBulkEditForm',
) )
@ -70,3 +74,38 @@ class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form):
(None, ('enabled', 'description')), (None, ('enabled', 'description')),
) )
nullable_fields = ('description',) nullable_fields = ('description',)
class TokenBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Token.objects.all(),
widget=forms.MultipleHiddenInput
)
write_enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label=_('Write enabled')
)
description = forms.CharField(
max_length=200,
required=False,
label=_('Description')
)
expires = forms.DateTimeField(
required=False,
widget=DateTimePicker(),
label=_('Expires')
)
allowed_ips = SimpleArrayField(
base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False,
label=_('Allowed IPs')
)
model = Token
fieldsets = (
(None, ('write_enabled', 'description', 'expires', 'allowed_ips')),
)
nullable_fields = (
'expires', 'description', 'allowed_ips',
)

View File

@ -1,9 +1,13 @@
from users.models import NetBoxGroup, NetBoxUser from django import forms
from django.utils.translation import gettext as _
from users.models import *
from utilities.forms import CSVModelForm from utilities.forms import CSVModelForm
__all__ = ( __all__ = (
'GroupImportForm', 'GroupImportForm',
'UserImportForm', 'UserImportForm',
'TokenImportForm',
) )
@ -30,3 +34,15 @@ class UserImportForm(CSVModelForm):
self.instance.set_password(self.cleaned_data.get('password')) self.instance.set_password(self.cleaned_data.get('password'))
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
class TokenImportForm(CSVModelForm):
key = forms.CharField(
label=_('Key'),
required=False,
help_text=_("If no key is provided, one will be generated automatically.")
)
class Meta:
model = Token
fields = ('user', 'key', 'write_enabled', 'expires', 'description',)

View File

@ -1,4 +1,7 @@
from django import forms from django import forms
from extras.forms.mixins import SavedFiltersMixin
from utilities.forms import FilterForm
from users.models import Token
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -7,11 +10,13 @@ from netbox.forms import NetBoxModelFilterSetForm
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission from users.models import NetBoxGroup, NetBoxUser, ObjectPermission
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.widgets import DateTimePicker
__all__ = ( __all__ = (
'GroupFilterForm', 'GroupFilterForm',
'ObjectPermissionFilterForm', 'ObjectPermissionFilterForm',
'UserFilterForm', 'UserFilterForm',
'TokenFilterForm',
) )
@ -109,3 +114,33 @@ class ObjectPermissionFilterForm(NetBoxModelFilterSetForm):
), ),
label=_('Can Delete'), label=_('Can Delete'),
) )
class TokenFilterForm(SavedFiltersMixin, FilterForm):
model = Token
fieldsets = (
(None, ('q', 'filter_id',)),
(_('Token'), ('user_id', 'write_enabled', 'expires', 'last_used')),
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
write_enabled = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Write Enabled'),
)
expires = forms.DateTimeField(
required=False,
label=_('Expires'),
widget=DateTimePicker()
)
last_used = forms.DateTimeField(
required=False,
label=_('Last Used'),
widget=DateTimePicker()
)

View File

@ -20,11 +20,13 @@ from utilities.permissions import qs_filter_from_constraints
from utilities.utils import flatten_dict from utilities.utils import flatten_dict
__all__ = ( __all__ = (
'UserTokenForm',
'GroupForm', 'GroupForm',
'ObjectPermissionForm', 'ObjectPermissionForm',
'TokenForm', 'TokenForm',
'UserConfigForm', 'UserConfigForm',
'UserForm', 'UserForm',
'TokenForm',
) )
@ -107,7 +109,7 @@ class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMe
] ]
class TokenForm(BootstrapMixin, forms.ModelForm): class UserTokenForm(BootstrapMixin, forms.ModelForm):
key = forms.CharField( key = forms.CharField(
label=_('Key'), label=_('Key'),
required=False, required=False,
@ -117,8 +119,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
base_field=IPNetworkFormField(validators=[prefix_validator]), base_field=IPNetworkFormField(validators=[prefix_validator]),
required=False, required=False,
label=_('Allowed IPs'), label=_('Allowed IPs'),
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' help_text=_(
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'), 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Example: <code>10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64</code>'
),
) )
class Meta: class Meta:
@ -138,6 +142,24 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
del self.fields['key'] del self.fields['key']
class TokenForm(UserTokenForm):
user = forms.ModelChoiceField(
queryset=get_user_model().objects.order_by(
'username'
),
required=False
)
class Meta:
model = Token
fields = [
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
]
widgets = {
'expires': DateTimePicker(),
}
class UserForm(BootstrapMixin, forms.ModelForm): class UserForm(BootstrapMixin, forms.ModelForm):
password = forms.CharField( password = forms.CharField(
label=_('Password'), label=_('Password'),

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.10 on 2023-07-25 15:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0004_netboxgroup_netboxuser'),
]
operations = [
migrations.CreateModel(
name='UserToken',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
'verbose_name': 'token',
},
bases=('users.token',),
),
]

View File

@ -26,6 +26,7 @@ __all__ = (
'ObjectPermission', 'ObjectPermission',
'Token', 'Token',
'UserConfig', 'UserConfig',
'UserToken',
) )
@ -273,13 +274,20 @@ class Token(models.Model):
blank=True, blank=True,
null=True, null=True,
verbose_name='Allowed IPs', verbose_name='Allowed IPs',
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' help_text=_(
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'), 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"'
),
) )
objects = RestrictedQuerySet.as_manager()
def __str__(self): def __str__(self):
return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial
def get_absolute_url(self):
return reverse('users:token', args=[self.pk])
@property @property
def partial(self): def partial(self):
return f'**********************************{self.key[-6:]}' if self.key else '' return f'**********************************{self.key[-6:]}' if self.key else ''
@ -314,6 +322,18 @@ class Token(models.Model):
return False return False
class UserToken(Token):
"""
Proxy model for users to manage their own API tokens.
"""
class Meta:
proxy = True
verbose_name = 'token'
def get_absolute_url(self):
return reverse('users:usertoken', args=[self.pk])
# #
# Permissions # Permissions
# #

View File

@ -1,8 +1,8 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from users.models import NetBoxGroup, NetBoxUser, ObjectPermission from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken
from .models import Token
__all__ = ( __all__ = (
'GroupTable', 'GroupTable',
@ -31,17 +31,28 @@ class TokenActionsColumn(columns.ActionsColumn):
} }
class TokenTable(NetBoxTable): class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
"""
key = columns.TemplateColumn( key = columns.TemplateColumn(
template_code=TOKEN verbose_name=_('Key'),
template_code=TOKEN,
) )
write_enabled = columns.BooleanColumn( write_enabled = columns.BooleanColumn(
verbose_name='Write' verbose_name=_('Write Enabled')
)
created = columns.DateColumn(
verbose_name=_('Created'),
)
expires = columns.DateColumn(
verbose_name=_('Expires'),
)
last_used = columns.DateTimeColumn(
verbose_name=_('Last Used'),
) )
created = columns.DateColumn()
expired = columns.DateColumn()
last_used = columns.DateTimeColumn()
allowed_ips = columns.TemplateColumn( allowed_ips = columns.TemplateColumn(
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS template_code=ALLOWED_IPS
) )
actions = TokenActionsColumn( actions = TokenActionsColumn(
@ -49,10 +60,26 @@ class TokenTable(NetBoxTable):
extra_buttons=COPY_BUTTON extra_buttons=COPY_BUTTON
) )
class Meta(NetBoxTable.Meta):
model = UserToken
fields = (
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)
class TokenTable(UserTokenTable):
"""
General-purpose table for API token management.
"""
user = tables.Column(
linkify=True,
verbose_name=_('User')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Token model = Token
fields = ( fields = (
'pk', 'description', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'pk', 'id', 'key', 'user', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
) )

View File

@ -2,7 +2,7 @@ from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from users.models import * from users.models import *
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases, create_test_user
class UserTestCase( class UserTestCase(
@ -149,3 +149,53 @@ class ObjectPermissionTestCase(
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New description', 'description': 'New description',
} }
class TokenTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = Token
maxDiff = None
@classmethod
def setUpTestData(cls):
users = (
create_test_user('User 1'),
create_test_user('User 2'),
)
tokens = (
Token(key='123456790123456789012345678901234567890A', user=users[0]),
Token(key='123456790123456789012345678901234567890B', user=users[0]),
Token(key='123456790123456789012345678901234567890C', user=users[1]),
)
Token.objects.bulk_create(tokens)
cls.form_data = {
'user': users[0].pk,
'description': 'testdescription',
}
cls.csv_data = (
"key,user,description",
f"123456790123456789012345678901234567890D,{users[0].pk},testdescriptionD",
f"123456790123456789012345678901234567890E,{users[1].pk},testdescriptionE",
f"123456790123456789012345678901234567890F,{users[1].pk},testdescriptionF",
)
cls.csv_update_data = (
"id,description",
f"{tokens[0].pk},testdescriptionH",
f"{tokens[1].pk},testdescriptionI",
f"{tokens[2].pk},testdescriptionJ",
)
cls.bulk_edit_data = {
'description': 'newdescription',
}

View File

@ -11,9 +11,17 @@ urlpatterns = [
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'token'))), path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
# Tokens
path('tokens/', views.TokenListView.as_view(), name='token_list'),
path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'),
path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'),
path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
# Users # Users
path('users/', views.UserListView.as_view(), name='netboxuser_list'), path('users/', views.UserListView.as_view(), name='netboxuser_list'),

View File

@ -24,7 +24,7 @@ from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import register_model_view from utilities.views import register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import Token, UserConfig, NetBoxGroup, NetBoxUser, ObjectPermission from .models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserConfig, UserToken
# #
@ -249,53 +249,61 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
# #
# API tokens # User views for token management
# #
class TokenListView(LoginRequiredMixin, View): class UserTokenListView(LoginRequiredMixin, View):
def get(self, request): def get(self, request):
tokens = UserToken.objects.filter(user=request.user)
tokens = Token.objects.filter(user=request.user) table = tables.UserTokenTable(tokens)
table = tables.TokenTable(tokens)
table.configure(request) table.configure(request)
return render(request, 'users/account/api_tokens.html', { return render(request, 'users/account/token_list.html', {
'tokens': tokens, 'tokens': tokens,
'active_tab': 'api-tokens', 'active_tab': 'api-tokens',
'table': table, 'table': table,
}) })
@register_model_view(Token, 'edit') @register_model_view(UserToken)
class TokenEditView(LoginRequiredMixin, View): class UserTokenView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
return render(request, 'users/account/token.html', {
'object': token,
'key': key,
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(LoginRequiredMixin, View):
def get(self, request, pk=None): def get(self, request, pk=None):
if pk: if pk:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
else: else:
token = Token(user=request.user) token = UserToken(user=request.user)
form = forms.UserTokenForm(instance=token)
form = forms.TokenForm(instance=token)
return render(request, 'generic/object_edit.html', { return render(request, 'generic/object_edit.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:usertoken_list'),
}) })
def post(self, request, pk=None): def post(self, request, pk=None):
if pk: if pk:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
form = forms.TokenForm(request.POST, instance=token) form = forms.UserTokenForm(request.POST, instance=token)
else: else:
token = Token(user=request.user) token = UserToken(user=request.user)
form = forms.TokenForm(request.POST) form = forms.UserTokenForm(request.POST)
if form.is_valid(): if form.is_valid():
token = form.save(commit=False) token = form.save(commit=False)
token.user = request.user token.user = request.user
token.save() token.save()
@ -304,7 +312,7 @@ class TokenEditView(LoginRequiredMixin, View):
messages.success(request, msg) messages.success(request, msg)
if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: if not pk and not settings.ALLOW_TOKEN_RETRIEVAL:
return render(request, 'users/account/api_token.html', { return render(request, 'users/account/token.html', {
'object': token, 'object': token,
'key': token.key, 'key': token.key,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:token_list'),
@ -312,53 +320,91 @@ class TokenEditView(LoginRequiredMixin, View):
elif '_addanother' in request.POST: elif '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
else: else:
return redirect('users:token_list') return redirect('users:usertoken_list')
return render(request, 'generic/object_edit.html', { return render(request, 'generic/object_edit.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:usertoken_list'),
'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL
}) })
@register_model_view(Token, 'delete') @register_model_view(UserToken, 'delete')
class TokenDeleteView(LoginRequiredMixin, View): class UserTokenDeleteView(LoginRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
initial_data = {
'return_url': reverse('users:token_list'),
}
form = ConfirmationForm(initial=initial_data)
return render(request, 'generic/object_delete.html', { return render(request, 'generic/object_delete.html', {
'object': token, 'object': token,
'form': form, 'form': ConfirmationForm(),
'return_url': reverse('users:token_list'), 'return_url': reverse('users:usertoken_list'),
}) })
def post(self, request, pk): def post(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = ConfirmationForm(request.POST) form = ConfirmationForm(request.POST)
if form.is_valid(): if form.is_valid():
token.delete() token.delete()
messages.success(request, "Token deleted") messages.success(request, "Token deleted")
return redirect('users:token_list') return redirect('users:usertoken_list')
return render(request, 'generic/object_delete.html', { return render(request, 'generic/object_delete.html', {
'object': token, 'object': token,
'form': form, 'form': form,
'return_url': reverse('users:token_list'), 'return_url': reverse('users:usertoken_list'),
}) })
#
# Tokens
#
class TokenListView(generic.ObjectListView):
queryset = Token.objects.all()
filterset = filtersets.TokenFilterSet
filterset_form = forms.TokenFilterForm
table = tables.TokenTable
@register_model_view(Token)
class TokenView(generic.ObjectView):
queryset = Token.objects.all()
@register_model_view(Token, 'edit')
class TokenEditView(generic.ObjectEditView):
queryset = Token.objects.all()
form = forms.TokenForm
@register_model_view(Token, 'delete')
class TokenDeleteView(generic.ObjectDeleteView):
queryset = Token.objects.all()
class TokenBulkImportView(generic.BulkImportView):
queryset = Token.objects.all()
model_form = forms.TokenImportForm
class TokenBulkEditView(generic.BulkEditView):
queryset = Token.objects.all()
table = tables.TokenTable
form = forms.TokenBulkEditForm
class TokenBulkDeleteView(generic.BulkDeleteView):
queryset = Token.objects.all()
table = tables.TokenTable
# #
# Users # Users
# #
class UserListView(generic.ObjectListView): class UserListView(generic.ObjectListView):
queryset = NetBoxUser.objects.all() queryset = NetBoxUser.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet
@ -413,7 +459,6 @@ class UserBulkDeleteView(generic.BulkDeleteView):
# Groups # Groups
# #
class GroupListView(generic.ObjectListView): class GroupListView(generic.ObjectListView):
queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) queryset = NetBoxGroup.objects.annotate(users_count=Count('user'))
filterset = filtersets.GroupFilterSet filterset = filtersets.GroupFilterSet
@ -448,11 +493,11 @@ class GroupBulkDeleteView(generic.BulkDeleteView):
filterset = filtersets.GroupFilterSet filterset = filtersets.GroupFilterSet
table = tables.GroupTable table = tables.GroupTable
# #
# ObjectPermissions # ObjectPermissions
# #
class ObjectPermissionListView(generic.ObjectListView): class ObjectPermissionListView(generic.ObjectListView):
queryset = ObjectPermission.objects.all() queryset = ObjectPermission.objects.all()
filterset = filtersets.ObjectPermissionFilterSet filterset = filtersets.ObjectPermissionFilterSet

View File

@ -0,0 +1,93 @@
from django.apps import apps
from django.db.models import F
from django.db.models.signals import post_delete, post_save
from netbox.registry import registry
from .fields import CounterCacheField
def get_counters_for_model(model):
"""
Return field mappings for all counters registered to the given model.
"""
return registry['counter_fields'][model].items()
def update_counter(model, pk, counter_name, value):
"""
Increment or decrement a counter field on an object identified by its model and primary key (PK). Positive values
will increment; negative values will decrement.
"""
model.objects.filter(pk=pk).update(
**{counter_name: F(counter_name) + value}
)
#
# Signal handlers
#
def post_save_receiver(sender, instance, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is created or modified.
"""
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
new_pk = getattr(instance, field_name, None)
old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None
# Update the counters on the old and/or new parents as needed
if old_pk is not None:
update_counter(parent_model, old_pk, counter_name, -1)
if new_pk is not None:
update_counter(parent_model, new_pk, counter_name, 1)
def post_delete_receiver(sender, instance, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
parent_pk = getattr(instance, field_name, None)
# Decrement the parent's counter by one
if parent_pk is not None:
update_counter(parent_model, parent_pk, counter_name, -1)
#
# Registration
#
def connect_counters(*models):
"""
Register counter fields and connect post_save & post_delete signal handlers for the affected models.
"""
for model in models:
# Find all CounterCacheFields on the model
counter_fields = [
field for field in model._meta.get_fields() if type(field) is CounterCacheField
]
for field in counter_fields:
to_model = apps.get_model(field.to_model_name)
# Register the counter in the registry
change_tracking_fields = registry['counter_fields'][to_model]
change_tracking_fields[f"{field.to_field_name}_id"] = field.name
# Connect the post_save and post_delete handlers
post_save.connect(
post_save_receiver,
sender=to_model,
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)
post_delete.connect(
post_delete_receiver,
sender=to_model,
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)

View File

@ -2,6 +2,7 @@ from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from utilities.ordering import naturalize from utilities.ordering import naturalize
from .forms.widgets import ColorSelect from .forms.widgets import ColorSelect
@ -9,6 +10,7 @@ from .validators import ColorValidator
__all__ = ( __all__ = (
'ColorField', 'ColorField',
'CounterCacheField',
'NaturalOrderingField', 'NaturalOrderingField',
'NullableCharField', 'NullableCharField',
'RestrictedGenericForeignKey', 'RestrictedGenericForeignKey',
@ -143,3 +145,43 @@ class RestrictedGenericForeignKey(GenericForeignKey):
self.name, self.name,
False, False,
) )
class CounterCacheField(models.BigIntegerField):
"""
Counter field to keep track of related model counts.
"""
def __init__(self, to_model, to_field, *args, **kwargs):
if not isinstance(to_model, str):
raise TypeError(
_("%s(%r) is invalid. to_model parameter to CounterCacheField must be "
"a string in the format 'app.model'")
% (
self.__class__.__name__,
to_model,
)
)
if not isinstance(to_field, str):
raise TypeError(
_("%s(%r) is invalid. to_field parameter to CounterCacheField must be "
"a string in the format 'field'")
% (
self.__class__.__name__,
to_field,
)
)
self.to_model_name = to_model
self.to_field_name = to_field
kwargs['default'] = kwargs.get('default', 0)
kwargs['editable'] = False
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs["to_model"] = self.to_model_name
kwargs["to_field"] = self.to_field_name
return name, path, args, kwargs

View File

View File

@ -0,0 +1,52 @@
from collections import defaultdict
from django.core.management.base import BaseCommand
from django.db.models import Count, OuterRef, Subquery
from netbox.registry import registry
class Command(BaseCommand):
help = "Force a recalculation of all cached counter fields"
@staticmethod
def collect_models():
"""
Query the registry to find all models which have one or more counter fields. Return a mapping of counter fields
to related query names for each model.
"""
models = defaultdict(dict)
for model, field_mappings in registry['counter_fields'].items():
for field_name, counter_name in field_mappings.items():
fk_field = model._meta.get_field(field_name) # Interface.device
parent_model = fk_field.related_model # Device
related_query_name = fk_field.related_query_name() # 'interfaces'
models[parent_model][counter_name] = related_query_name
return models
def update_counts(self, model, field_name, related_query):
"""
Perform a bulk update for the given model and counter field. For example,
update_counts(Device, '_interface_count', 'interfaces')
will effectively set
Device.objects.update(_interface_count=Count('interfaces'))
"""
self.stdout.write(f'Updating {model.__name__} {field_name}...')
subquery = Subquery(
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
)
return model.objects.update(**{
field_name: subquery
})
def handle(self, *model_names, **options):
for model, mappings in self.collect_models().items():
for field_name, related_query in mappings.items():
self.update_counts(model, field_name, related_query)
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -0,0 +1,69 @@
from django.test import TestCase
from dcim.models import *
from utilities.testing.utils import create_test_device
class CountersTest(TestCase):
"""
Validate the operation of dict_to_filter_params().
"""
@classmethod
def setUpTestData(cls):
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
# Create interfaces
Interface.objects.create(device=device1, name='Interface 1')
Interface.objects.create(device=device1, name='Interface 2')
Interface.objects.create(device=device2, name='Interface 3')
Interface.objects.create(device=device2, name='Interface 4')
def test_interface_count_creation(self):
"""
When a tracked object (Interface) is added the tracking counter should be updated.
"""
device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2)
self.assertEqual(device2.interface_count, 2)
Interface.objects.create(device=device1, name='Interface 5')
Interface.objects.create(device=device2, name='Interface 6')
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3)
def test_interface_count_deletion(self):
"""
When a tracked object (Interface) is deleted the tracking counter should be updated.
"""
device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2)
self.assertEqual(device2.interface_count, 2)
Interface.objects.get(name='Interface 1').delete()
Interface.objects.get(name='Interface 3').delete()
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 1)
def test_interface_count_move(self):
"""
When a tracked object (Interface) is moved the tracking counter should be updated.
"""
device1, device2 = Device.objects.all()
self.assertEqual(device1.interface_count, 2)
self.assertEqual(device2.interface_count, 2)
interface1 = Interface.objects.get(name='Interface 1')
interface1.device = device2
interface1.save()
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)

View File

@ -0,0 +1,78 @@
from django.db.models.query_utils import DeferredAttribute
from netbox.registry import registry
class Tracker:
"""
An ephemeral instance employed to record which tracked fields on an instance have been modified.
"""
def __init__(self):
self._changed_fields = {}
def __contains__(self, item):
return item in self._changed_fields
def set(self, name, value):
"""
Mark an attribute as having been changed and record its original value.
"""
self._changed_fields[name] = value
def get(self, name):
"""
Return the original value of a changed field. Raises KeyError if name is not found.
"""
return self._changed_fields[name]
def clear(self, *names):
"""
Clear any fields that were recorded as having been changed.
"""
for name in names:
self._changed_fields.pop(name, None)
else:
self._changed_fields = {}
class TrackingModelMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Mark the instance as initialized, to enable our custom __setattr__()
self._initialized = True
@property
def tracker(self):
"""
Return the Tracker instance for this instance, first creating it if necessary.
"""
if not hasattr(self._state, "_tracker"):
self._state._tracker = Tracker()
return self._state._tracker
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Clear any tracked fields now that changes have been saved
update_fields = kwargs.get('update_fields', [])
self.tracker.clear(*update_fields)
def __setattr__(self, name, value):
if hasattr(self, "_initialized"):
# Record any changes to a tracked field
if name in registry['counter_fields'][self.__class__]:
if name not in self.tracker:
# The attribute has been created or changed
if name in self.__dict__:
old_value = getattr(self, name)
if value != old_value:
self.tracker.set(name, old_value)
else:
self.tracker.set(name, DeferredAttribute)
elif value == self.tracker.get(name):
# A previously changed attribute has been restored
self.tracker.clear(name)
super().__setattr__(name, value)

View File

@ -80,12 +80,15 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
# Counter fields
interface_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
] ]
validators = [] validators = []
@ -98,6 +101,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'interface_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -6,3 +6,8 @@ class VirtualizationConfig(AppConfig):
def ready(self): def ready(self):
from . import search from . import search
from .models import VirtualMachine
from utilities.counters import connect_counters
# Register counters
connect_counters(VirtualMachine)

View File

@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def populate_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
vms = list(VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True)))
for vm in vms:
vm.interface_count = vm._interface_count
VirtualMachine.objects.bulk_update(vms, ['interface_count'])
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0034_standardize_description_comments'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='interface_count',
field=utilities.fields.CounterCacheField(
default=0, to_field='virtual_machine', to_model='virtualization.VMInterface'
),
),
migrations.RunPython(
code=populate_virtualmachine_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -11,9 +11,10 @@ from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from netbox.config import get_config from netbox.config import get_config
from netbox.models import NetBoxModel, PrimaryModel from netbox.models import NetBoxModel, PrimaryModel
from utilities.fields import NaturalOrderingField from utilities.fields import CounterCacheField, NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from virtualization.choices import * from virtualization.choices import *
__all__ = ( __all__ = (
@ -120,6 +121,12 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
verbose_name='Disk (GB)' verbose_name='Disk (GB)'
) )
# Counter fields
interface_count = CounterCacheField(
to_model='virtualization.VMInterface',
to_field='virtual_machine'
)
# Generic relation # Generic relation
contacts = GenericRelation( contacts = GenericRelation(
to='tenancy.ContactAssignment' to='tenancy.ContactAssignment'
@ -222,7 +229,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
return None return None
class VMInterface(NetBoxModel, BaseInterface): class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
virtual_machine = models.ForeignKey( virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -1,10 +1,11 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _
from dcim.tables.devices import BaseInterfaceTable from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from netbox.tables import NetBoxTable, columns
__all__ = ( __all__ = (
'VirtualMachineTable', 'VirtualMachineTable',
'VirtualMachineVMInterfaceTable', 'VirtualMachineVMInterfaceTable',
@ -70,6 +71,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='virtualization:virtualmachine_list' url_name='virtualization:virtualmachine_list'
) )
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = VirtualMachine model = VirtualMachine

View File

@ -349,7 +349,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
template_name = 'virtualization/virtualmachine/interfaces.html' template_name = 'virtualization/virtualmachine/interfaces.html'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
badge=lambda obj: obj.interfaces.count(), badge=lambda obj: obj.interface_count,
permission='virtualization.view_vminterface', permission='virtualization.view_vminterface',
weight=500 weight=500
) )