13150 merge feature

This commit is contained in:
Arthur 2023-07-26 12:14:52 +07:00
commit 341a86a4e1
67 changed files with 1596 additions and 269 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

@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
!!! tip !!! tip
NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
### Out-of-band (OOB) IP Address
Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network.
### Cluster ### Cluster
If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.) If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.)

View File

@ -7,16 +7,47 @@
* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later. * PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model.
### New Features
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
* Users
* Groups
* Object permissions
* API tokens
* Configuration revisions
The admin UI is scheduled for removal in NetBox v4.0.
#### User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
Users can now bookmark their most commonly-visited objects in NetBox. Bookmarks will display both on the dashboard (if configured) and on a user-specific bookmarks view.
#### Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
Select and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields.
#### Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
Tags may now be restricted to use with designated object types. Tags that have no specific object types assigned may be used with any object that supports tag assignment.
### Enhancements ### Enhancements
* [#6347](https://github.com/netbox-community/netbox/issues/6347) - Cache the number of assigned components for devices and virtual machines
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model * [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one * [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Other Changes ### Other Changes
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates * [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view * [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model * [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL * [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11

View File

@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
console_server_port_template_count = serializers.IntegerField(read_only=True)
power_port_template_count = serializers.IntegerField(read_only=True)
power_outlet_template_count = serializers.IntegerField(read_only=True)
interface_template_count = serializers.IntegerField(read_only=True)
front_port_template_count = serializers.IntegerField(read_only=True)
rear_port_template_count = serializers.IntegerField(read_only=True)
device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
] ]
@ -663,20 +679,35 @@ class DeviceSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
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)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
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',
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'last_updated', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
@ -698,9 +729,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',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
'created', 'last_updated', 'config_template', '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))
@ -1139,13 +1172,15 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False, allow_null=True, default=None) master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
# Counter fields
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = [ fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'member_count', 'created', 'last_updated', 'created', 'last_updated', 'member_count',
] ]

View File

@ -579,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet):
# #
class VirtualChassisViewSet(NetBoxModelViewSet): class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate( queryset = VirtualChassis.objects.prefetch_related('tags')
member_count=count_related(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filtersets.VirtualChassisFilterSet filterset_class = filtersets.VirtualChassisFilterSet
brief_prefetch_fields = ['master'] brief_prefetch_fields = ['master']

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, DeviceType, VirtualChassis
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, DeviceType, VirtualChassis)

View File

@ -941,6 +941,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_has_primary_ip', method='_has_primary_ip',
label=_('Has a primary IP'), label=_('Has a primary IP'),
) )
has_oob_ip = django_filters.BooleanFilter(
method='_has_oob_ip',
label=_('Has an out-of-band IP'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis', field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
@ -996,6 +1000,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'), label=_('Primary IPv6 (ID)'),
) )
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip',
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
class Meta: class Meta:
model = Device model = Device
@ -1020,6 +1029,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.filter(params) return queryset.filter(params)
return queryset.exclude(params) return queryset.exclude(params)
def _has_oob_ip(self, queryset, name, value):
params = Q(oob_ip__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value): def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value) return queryset.exclude(virtual_chassis__isnull=value)

View File

@ -629,7 +629,7 @@ class DeviceFilterForm(
('Components', ( ('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) ('Miscellaneous', ('has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -723,6 +723,13 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
has_oob_ip = forms.NullBooleanField(
required=False,
label='Has an OOB IP',
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
virtual_chassis_member = forms.NullBooleanField( virtual_chassis_member = forms.NullBooleanField(
required=False, required=False,
label='Virtual chassis member', label='Virtual chassis member',

View File

@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'comments', 'tags', 'local_context_data' 'comments', 'tags', 'local_context_data',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -460,6 +460,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if self.instance.pk: if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
oob_ip_choices = [(None, '---------')]
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
@ -475,6 +476,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if interface_ips: if interface_ips:
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list)) ip_choices.append(('Interface IPs', ip_list))
oob_ip_choices.extend(ip_list)
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, address__family=family,
@ -485,6 +487,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list)) ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices self.fields['primary_ip{}'.format(family)].choices = ip_choices
self.fields['oob_ip'].choices = oob_ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another. # can be flipped from one face to another.
@ -504,6 +507,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip4'].widget.attrs['readonly'] = True self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
self.fields['oob_ip'].choices = []
self.fields['oob_ip'].widget.attrs['readonly'] = True
# Rack position # Rack position
position = self.data.get('position') or self.initial.get('position') position = self.data.get('position') or self.initial.get('position')

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.9 on 2023-07-24 20:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0066_iprange_mark_utilized'),
('dcim', '0174_rack_starting_unit'),
]
operations = [
migrations.AddField(
model_name='device',
name='oob_ip',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='ipam.ipaddress',
),
),
]

View File

@ -0,0 +1,108 @@
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', '0175_device_oob_ip'),
]
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

@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
device_types = list(DeviceType.objects.all().annotate(
_console_port_template_count=Count('consoleporttemplates', distinct=True),
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
_power_port_template_count=Count('powerporttemplates', distinct=True),
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
_interface_template_count=Count('interfacetemplates', distinct=True),
_front_port_template_count=Count('frontporttemplates', distinct=True),
_rear_port_template_count=Count('rearporttemplates', distinct=True),
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
))
for devicetype in device_types:
devicetype.console_port_template_count = devicetype._console_port_template_count
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
devicetype.power_port_template_count = devicetype._power_port_template_count
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
devicetype.interface_template_count = devicetype._interface_template_count
devicetype.front_port_template_count = devicetype._front_port_template_count
devicetype.rear_port_template_count = devicetype._rear_port_template_count
devicetype.device_bay_template_count = devicetype._device_bay_template_count
devicetype.module_bay_template_count = devicetype._module_bay_template_count
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
DeviceType.objects.bulk_update(device_types, [
'console_port_template_count',
'console_server_port_template_count',
'power_port_template_count',
'power_outlet_template_count',
'interface_template_count',
'front_port_template_count',
'rear_port_template_count',
'device_bay_template_count',
'module_bay_template_count',
'inventory_item_template_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0176_device_component_counters'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='console_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='console_server_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_outlet_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='interface_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='front_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='rear_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='device_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='module_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='inventory_item_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'),
),
migrations.RunPython(
recalculate_devicetype_template_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0177_devicetype_component_counters'),
]
operations = [
migrations.AddField(
model_name='virtualchassis',
name='member_count',
field=utilities.fields.CounterCacheField(
default=0, to_field='virtual_chassis', to_model='dcim.Device'
),
),
migrations.RunPython(
code=populate_virtualchassis_members,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField 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.tracking import TrackingModelMixin
from .device_components import ( from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort, RearPort,
@ -32,7 +33,7 @@ __all__ = (
) )
class ComponentTemplateModel(ChangeLoggedModel): class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
device_type = models.ForeignKey( device_type = models.ForeignKey(
to='dcim.DeviceType', to='dcim.DeviceType',
on_delete=models.CASCADE, on_delete=models.CASCADE,

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,8 @@ 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 utilities.tracking import TrackingModelMixin
from .device_components import * from .device_components import *
from .mixins import WeightMixin from .mixins import WeightMixin
@ -128,6 +129,48 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True blank=True
) )
# Counter fields
console_port_template_count = CounterCacheField(
to_model='dcim.ConsolePortTemplate',
to_field='device_type'
)
console_server_port_template_count = CounterCacheField(
to_model='dcim.ConsoleServerPortTemplate',
to_field='device_type'
)
power_port_template_count = CounterCacheField(
to_model='dcim.PowerPortTemplate',
to_field='device_type'
)
power_outlet_template_count = CounterCacheField(
to_model='dcim.PowerOutletTemplate',
to_field='device_type'
)
interface_template_count = CounterCacheField(
to_model='dcim.InterfaceTemplate',
to_field='device_type'
)
front_port_template_count = CounterCacheField(
to_model='dcim.FrontPortTemplate',
to_field='device_type'
)
rear_port_template_count = CounterCacheField(
to_model='dcim.RearPortTemplate',
to_field='device_type'
)
device_bay_template_count = CounterCacheField(
to_model='dcim.DeviceBayTemplate',
to_field='device_type'
)
module_bay_template_count = CounterCacheField(
to_model='dcim.ModuleBayTemplate',
to_field='device_type'
)
inventory_item_template_count = CounterCacheField(
to_model='dcim.InventoryItemTemplate',
to_field='device_type'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -469,7 +512,7 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save() interface.save()
class Device(PrimaryModel, ConfigContextModel): class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@ -591,6 +634,14 @@ class Device(PrimaryModel, ConfigContextModel):
null=True, null=True,
verbose_name='Primary IPv6' verbose_name='Primary IPv6'
) )
oob_ip = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name='Out-of-band IP'
)
cluster = models.ForeignKey( cluster = models.ForeignKey(
to='virtualization.Cluster', to='virtualization.Cluster',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -639,6 +690,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'
@ -774,7 +867,7 @@ class Device(PrimaryModel, ConfigContextModel):
except DeviceType.DoesNotExist: except DeviceType.DoesNotExist:
pass pass
# Validate primary IP addresses # Validate primary & OOB IP addresses
vc_interfaces = self.vc_interfaces(if_master=False) vc_interfaces = self.vc_interfaces(if_master=False)
if self.primary_ip4: if self.primary_ip4:
if self.primary_ip4.family != 4: if self.primary_ip4.family != 4:
@ -802,6 +895,15 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({ raise ValidationError({
'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device." 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
}) })
if self.oob_ip:
if self.oob_ip.assigned_object in vc_interfaces:
pass
elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
})
# Validate manufacturer/platform # Validate manufacturer/platform
if hasattr(self, 'device_type') and self.platform: if hasattr(self, 'device_type') and self.platform:
@ -1147,6 +1249,12 @@ class VirtualChassis(PrimaryModel):
blank=True blank=True
) )
# Counter fields
member_count = CounterCacheField(
to_model='dcim.Device',
to_field='virtual_chassis'
)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
verbose_name_plural = 'virtual chassis' verbose_name_plural = 'virtual chassis'

View File

@ -1,11 +1,11 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
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__ = (
@ -215,6 +215,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True, linkify=True,
verbose_name=_('IPv6 Address') verbose_name=_('IPv6 Address')
) )
oob_ip = tables.Column(
linkify=True,
verbose_name='OOB IP'
)
cluster = tables.Column( cluster = tables.Column(
verbose_name=_('Cluster'), verbose_name=_('Cluster'),
linkify=True linkify=True
@ -247,6 +251,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
@ -254,8 +288,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -1,5 +1,6 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _
from dcim import models from dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
@ -87,11 +88,6 @@ class DeviceTypeTable(NetBoxTable):
is_full_depth = columns.BooleanColumn( is_full_depth = columns.BooleanColumn(
verbose_name=_('Full Depth') verbose_name=_('Full Depth')
) )
instance_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name=_('Instances')
)
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )
@ -107,12 +103,48 @@ class DeviceTypeTable(NetBoxTable):
template_code=WEIGHT, template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit') order_by=('_abs_weight', 'weight_unit')
) )
instance_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name=_('Instances')
)
console_port_template_count = tables.Column(
verbose_name=_('Console ports')
)
console_server_port_template_count = tables.Column(
verbose_name=_('Console server ports')
)
power_port_template_count = tables.Column(
verbose_name=_('Power ports')
)
power_outlet_template_count = tables.Column(
verbose_name=_('Power outlets')
)
interface_template_count = tables.Column(
verbose_name=_('Interfaces')
)
front_port_template_count = tables.Column(
verbose_name=_('Front ports')
)
rear_port_template_count = tables.Column(
verbose_name=_('Rear ports')
)
device_bay_template_count = tables.Column(
verbose_name=_('Device bays')
)
module_bay_template_count = tables.Column(
verbose_name=_('Module bays')
)
inventory_item_template_count = tables.Column(
verbose_name=_('Inventory items')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.DeviceType model = models.DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -951,7 +951,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_consoleports' viewname = 'dcim:devicetype_consoleports'
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(), badge=lambda obj: obj.console_port_template_count,
permission='dcim.view_consoleporttemplate', permission='dcim.view_consoleporttemplate',
weight=550, weight=550,
hide_if_empty=True hide_if_empty=True
@ -966,7 +966,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_consoleserverports' viewname = 'dcim:devicetype_consoleserverports'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(), badge=lambda obj: obj.console_server_port_template_count,
permission='dcim.view_consoleserverporttemplate', permission='dcim.view_consoleserverporttemplate',
weight=560, weight=560,
hide_if_empty=True hide_if_empty=True
@ -981,7 +981,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_powerports' viewname = 'dcim:devicetype_powerports'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(), badge=lambda obj: obj.power_port_template_count,
permission='dcim.view_powerporttemplate', permission='dcim.view_powerporttemplate',
weight=570, weight=570,
hide_if_empty=True hide_if_empty=True
@ -996,7 +996,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_poweroutlets' viewname = 'dcim:devicetype_poweroutlets'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(), badge=lambda obj: obj.power_outlet_template_count,
permission='dcim.view_poweroutlettemplate', permission='dcim.view_poweroutlettemplate',
weight=580, weight=580,
hide_if_empty=True hide_if_empty=True
@ -1011,7 +1011,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_interfaces' viewname = 'dcim:devicetype_interfaces'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(), badge=lambda obj: obj.interface_template_count,
permission='dcim.view_interfacetemplate', permission='dcim.view_interfacetemplate',
weight=520, weight=520,
hide_if_empty=True hide_if_empty=True
@ -1026,7 +1026,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_frontports' viewname = 'dcim:devicetype_frontports'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(), badge=lambda obj: obj.front_port_template_count,
permission='dcim.view_frontporttemplate', permission='dcim.view_frontporttemplate',
weight=530, weight=530,
hide_if_empty=True hide_if_empty=True
@ -1041,7 +1041,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_rearports' viewname = 'dcim:devicetype_rearports'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(), badge=lambda obj: obj.rear_port_template_count,
permission='dcim.view_rearporttemplate', permission='dcim.view_rearporttemplate',
weight=540, weight=540,
hide_if_empty=True hide_if_empty=True
@ -1056,7 +1056,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_modulebays' viewname = 'dcim:devicetype_modulebays'
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.modulebaytemplates.count(), badge=lambda obj: obj.module_bay_template_count,
permission='dcim.view_modulebaytemplate', permission='dcim.view_modulebaytemplate',
weight=510, weight=510,
hide_if_empty=True hide_if_empty=True
@ -1071,7 +1071,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_devicebays' viewname = 'dcim:devicetype_devicebays'
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.devicebaytemplates.count(), badge=lambda obj: obj.device_bay_template_count,
permission='dcim.view_devicebaytemplate', permission='dcim.view_devicebaytemplate',
weight=500, weight=500,
hide_if_empty=True hide_if_empty=True
@ -1086,7 +1086,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
viewname = 'dcim:devicetype_inventoryitems' viewname = 'dcim:devicetype_inventoryitems'
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitemtemplates.count(), badge=lambda obj: obj.inventory_item_template_count,
permission='dcim.view_invenotryitemtemplate', permission='dcim.view_invenotryitemtemplate',
weight=590, weight=590,
hide_if_empty=True hide_if_empty=True
@ -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
@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView):
queryset = Interface.objects.all() queryset = Interface.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
# Get assigned VDC's # Get assigned VDCs
vdc_table = tables.VirtualDeviceContextTable( vdc_table = tables.VirtualDeviceContextTable(
data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', exclude=(
'created', 'last_updated', 'actions', ), 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags',
'created', 'last_updated', 'actions',
),
orderable=False orderable=False
) )
@ -3225,9 +3227,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
# #
class VirtualChassisListView(generic.ObjectListView): class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.annotate( queryset = VirtualChassis.objects.all()
member_count=count_related(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filterset = filtersets.VirtualChassisFilterSet filterset = filtersets.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm filterset_form = forms.VirtualChassisFilterForm

View File

@ -261,7 +261,7 @@ class BookmarkTestCase(
def _get_url(self, action, instance=None): def _get_url(self, action, instance=None):
if action == 'list': if action == 'list':
return reverse('users:bookmarks') return reverse('account:bookmarks')
return super()._get_url(action, instance) return super()._get_url(action, instance)
def test_list_objects_anonymous(self): def test_list_objects_anonymous(self):

View File

@ -849,6 +849,24 @@ class IPAddress(PrimaryModel):
return self.address.version return self.address.version
return None return None
@property
def is_oob_ip(self):
if self.assigned_object:
parent = getattr(self.assigned_object, 'parent_object', None)
if parent.oob_ip_id == self.pk:
return True
return False
@property
def is_primary_ip(self):
if self.assigned_object:
parent = getattr(self.assigned_object, 'parent_object', None)
if self.family == 4 and parent.primary_ip4_id == self.pk:
return True
if self.family == 6 and parent.primary_ip6_id == self.pk:
return True
return False
def _set_mask_length(self, value): def _set_mask_length(self, value):
""" """
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

View File

@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs):
@receiver(pre_delete, sender=IPAddress) @receiver(pre_delete, sender=IPAddress)
def clear_primary_ip(instance, **kwargs): def clear_primary_ip(instance, **kwargs):
""" """
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP.
was a primary IP.
""" """
field_name = f'primary_ip{instance.family}' field_name = f'primary_ip{instance.family}'
device = Device.objects.filter(**{field_name: instance}).first() if device := Device.objects.filter(**{field_name: instance}).first():
if device:
device.save() device.save()
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
if virtualmachine:
virtualmachine.save() virtualmachine.save()
@receiver(pre_delete, sender=IPAddress)
def clear_oob_ip(instance, **kwargs):
"""
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
"""
if device := Device.objects.filter(oob_ip=instance).first():
device.save()

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

@ -32,10 +32,13 @@ _patterns = [
path('extras/', include('extras.urls')), path('extras/', include('extras.urls')),
path('ipam/', include('ipam.urls')), path('ipam/', include('ipam.urls')),
path('tenancy/', include('tenancy.urls')), path('tenancy/', include('tenancy.urls')),
path('user/', include('users.urls')), path('users/', include('users.urls')),
path('virtualization/', include('virtualization.urls')), path('virtualization/', include('virtualization.urls')),
path('wireless/', include('wireless.urls')), path('wireless/', include('wireless.urls')),
# Current user views
path('user/', include('users.account_urls')),
# HTMX views # HTMX views
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'), path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),

View File

@ -239,6 +239,17 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Out-of-band IP</th>
<td>
{% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% if object.cluster %} {% if object.cluster %}
<tr> <tr>
<th>Cluster</th> <th>Cluster</th>

View File

@ -68,6 +68,7 @@
{% if object.pk %} {% if object.pk %}
{% render_field form.primary_ip4 %} {% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %} {% render_field form.primary_ip6 %}
{% render_field form.oob_ip %}
{% endif %} {% endif %}
</div> </div>

View File

@ -31,6 +31,16 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Members</th>
<td>
{% if object.member_count %}
<a href="{% url 'dcim:device_list' %}?virtual_chassis_id={{ object.pk }}">{{ object.member_count }}</a>
{% else %}
{{ object.member_count }}
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -19,22 +19,22 @@
{% endif %} {% endif %}
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:profile' %}"> <a class="dropdown-item" href="{% url 'account:profile' %}">
<i class="mdi mdi-account"></i> Profile <i class="mdi mdi-account"></i> Profile
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:bookmarks' %}"> <a class="dropdown-item" href="{% url 'account:bookmarks' %}">
<i class="mdi mdi-bookmark"></i> Bookmarks <i class="mdi mdi-bookmark"></i> Bookmarks
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:preferences' %}"> <a class="dropdown-item" href="{% url 'account:preferences' %}">
<i class="mdi mdi-wrench"></i> Preferences <i class="mdi mdi-wrench"></i> Preferences
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:token_list' %}"> <a class="dropdown-item" href="{% url 'account:usertoken_list' %}">
<i class="mdi mdi-key"></i> API Tokens <i class="mdi mdi-key"></i> API Tokens
</a> </a>
</li> </li>

View File

@ -96,6 +96,14 @@
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr>
<td>Primary IP</td>
<td>{% checkmark object.is_primary_ip %}</td>
</tr>
<tr>
<td>OOB IP</td>
<td>{% checkmark object.is_oob_ip %}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

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

@ -4,21 +4,21 @@
{% block tabs %} {% block tabs %}
<ul class="nav nav-tabs px-3"> <ul class="nav nav-tabs px-3">
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">{% trans "Profile" %}</a> <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'account:profile' %}">{% trans "Profile" %}</a>
</li> </li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">{% trans "Bookmarks" %}</a> <a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
</li> </li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">{% trans "Preferences" %}</a> <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
</li> </li>
{% if not request.user.ldap_username %} {% if not request.user.ldap_username %}
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'users:change_password' %}">{% trans "Password" %}</a> <a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
</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 'account:usertoken_list' %}">{% trans "API Tokens" %}</a>
</li> </li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -9,7 +9,7 @@
<form method="post" class="form form-horizontal"> <form method="post" class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" /> <input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />
{# Table #} {# Table #}
<div class="row"> <div class="row">

View File

@ -13,7 +13,7 @@
{% render_field form.new_password2 %} {% render_field form.new_password2 %}
</div> </div>
<div class="text-end"> <div class="text-end">
<a href="{% url 'users:profile' %}" class="btn btn-outline-danger">Cancel</a> <a href="{% url 'account:profile' %}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Save</button> <button type="submit" name="_update" class="btn btn-primary">Save</button>
</div> </div>
</form> </form>

View File

@ -79,7 +79,7 @@
</div> </div>
<div class="text-end my-3"> <div class="text-end my-3">
<a class="btn btn-outline-secondary" href="{% url 'users:preferences' %}">Cancel</a> <a class="btn btn-outline-secondary" href="{% url 'account:preferences' %}">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Save </button> <button type="submit" name="_update" class="btn btn-primary">Save </button>
</div> </div>
</form> </form>

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 'account: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 'account: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

@ -0,0 +1,18 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'account'
urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
]

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('account: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

@ -6,14 +6,13 @@ from . import views
app_name = 'users' app_name = 'users'
urlpatterns = [ urlpatterns = [
# Account views # Tokens
path('profile/', views.ProfileView.as_view(), name='profile'), path('tokens/', views.TokenListView.as_view(), name='token_list'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'), path('tokens/add/', views.TokenEditView.as_view(), name='token_add'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'),
path('api-tokens/', views.TokenListView.as_view(), name='token_list'), path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'),
path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), path('tokens/<int:pk>/', include(get_model_urls('users', 'token'))),
path('api-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
# #
@ -193,7 +193,7 @@ class UserConfigView(LoginRequiredMixin, View):
form.save() form.save()
messages.success(request, "Your preferences have been updated.") messages.success(request, "Your preferences have been updated.")
return redirect('users:preferences') return redirect('account:preferences')
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -208,7 +208,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
# LDAP users cannot change their password here # LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None): if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('users:profile') return redirect('account:profile')
form = forms.PasswordChangeForm(user=request.user) form = forms.PasswordChangeForm(user=request.user)
@ -223,7 +223,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
form.save() form.save()
update_session_auth_hash(request, form.user) update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.") messages.success(request, "Your password has been changed successfully.")
return redirect('users:profile') return redirect('account:profile')
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
@ -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('account: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('account: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('account: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('account: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('account: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('account: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
) )