mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Merge branch 'feature' into oob_ip
This commit is contained in:
commit
e38ba1283e
@ -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).
|
||||||
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
100
netbox/dcim/migrations/0175_device_component_counters.py
Normal file
100
netbox/dcim/migrations/0175_device_component_counters.py
Normal 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
|
||||||
|
),
|
||||||
|
]
|
@ -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.
|
||||||
|
@ -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'
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
@ -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']),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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(),
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
|
@ -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 %}
|
||||||
|
69
netbox/templates/users/account/token.html
Normal file
69
netbox/templates/users/account/token.html
Normal 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 %}
|
@ -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>
|
56
netbox/templates/users/token.html
Normal file
56
netbox/templates/users/token.html
Normal 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 %}
|
@ -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"
|
|
||||||
|
@ -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
|
|
@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'GroupFilterSet',
|
'GroupFilterSet',
|
||||||
'ObjectPermissionFilterSet',
|
'ObjectPermissionFilterSet',
|
||||||
|
'TokenFilterSet',
|
||||||
'UserFilterSet',
|
'UserFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
)
|
||||||
|
@ -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',)
|
||||||
|
@ -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()
|
||||||
|
)
|
||||||
|
@ -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'),
|
||||||
|
25
netbox/users/migrations/0005_usertoken.py
Normal file
25
netbox/users/migrations/0005_usertoken.py
Normal 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',),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
}
|
||||||
|
@ -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'),
|
||||||
|
@ -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
|
||||||
|
93
netbox/utilities/counters.py
Normal file
93
netbox/utilities/counters.py
Normal 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}'
|
||||||
|
)
|
@ -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
|
||||||
|
0
netbox/utilities/management/__init__.py
Normal file
0
netbox/utilities/management/__init__.py
Normal file
0
netbox/utilities/management/commands/__init__.py
Normal file
0
netbox/utilities/management/commands/__init__.py
Normal 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.'))
|
69
netbox/utilities/tests/test_counters.py
Normal file
69
netbox/utilities/tests/test_counters.py
Normal 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)
|
78
netbox/utilities/tracking.py
Normal file
78
netbox/utilities/tracking.py
Normal 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)
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user