diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index fe2c08d56..41bf6cb31 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`. ## Stores +### `counter_fields` + +A dictionary mapping of models to foreign keys with which cached counter fields are associated. + ### `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). diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 2216e351c..c9f05cd93 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre !!! tip 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 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.) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index dc5280670..3e027ff4f 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -7,16 +7,47 @@ * 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. +### 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 +* [#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 * [#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 * [#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 * [#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 * [#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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 32943f468..04929b079 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer): weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=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: model = DeviceType fields = [ - 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', + 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', + '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_ip4 = 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() cluster = NestedClusterSerializer(required=False, allow_null=True) 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) 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: model = Device fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', - 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', - 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', - 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', - 'last_updated', + 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', + 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', + 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', + 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', + 'device_bay_count', 'module_bay_count', 'inventory_item_count', ] @extend_schema_field(NestedDeviceSerializer) @@ -698,9 +729,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', - 'created', 'last_updated', + 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', + 'config_template', '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)) @@ -1139,13 +1172,15 @@ class CablePathSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False, allow_null=True, default=None) + + # Counter fields member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis fields = [ 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', - 'member_count', 'created', 'last_updated', + 'created', 'last_updated', 'member_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e8a2eabbf..dfedc7432 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -579,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet): # class VirtualChassisViewSet(NetBoxModelViewSet): - queryset = VirtualChassis.objects.prefetch_related('tags').annotate( - member_count=count_related(Device, 'virtual_chassis') - ) + queryset = VirtualChassis.objects.prefetch_related('tags') serializer_class = serializers.VirtualChassisSerializer filterset_class = filtersets.VirtualChassisFilterSet brief_prefetch_fields = ['master'] diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index bfb09e601..78ff0d4c1 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -9,7 +9,8 @@ class DCIMConfig(AppConfig): def ready(self): from . import signals, search - from .models import CableTermination + from .models import CableTermination, Device, DeviceType, VirtualChassis + from utilities.counters import connect_counters # Register denormalized fields denormalized.register(CableTermination, '_device', { @@ -24,3 +25,6 @@ class DCIMConfig(AppConfig): denormalized.register(CableTermination, '_location', { '_site': 'site', }) + + # Register counters + connect_counters(Device, DeviceType, VirtualChassis) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 724567666..e88fc120d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -941,6 +941,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter method='_has_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( field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), @@ -996,6 +1000,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter queryset=IPAddress.objects.all(), label=_('Primary IPv6 (ID)'), ) + oob_ip_id = django_filters.ModelMultipleChoiceFilter( + field_name='oob_ip', + queryset=IPAddress.objects.all(), + label=_('OOB IP (ID)'), + ) class Meta: model = Device @@ -1020,6 +1029,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.filter(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): return queryset.exclude(virtual_chassis__isnull=value) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0a4a22a70..06d38627d 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -629,7 +629,7 @@ class DeviceFilterForm( ('Components', ( '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( queryset=Region.objects.all(), @@ -723,6 +723,13 @@ class DeviceFilterForm( 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( required=False, label='Virtual chassis member', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 04f976d94..067cf2bda 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm): model = Device fields = [ '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', - 'comments', 'tags', 'local_context_data' + 'comments', 'tags', 'local_context_data', ] def __init__(self, *args, **kwargs): @@ -460,6 +460,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): if self.instance.pk: # Compile list of choices for primary IPv4 and IPv6 addresses + oob_ip_choices = [(None, '---------')] for family in [4, 6]: ip_choices = [(None, '---------')] @@ -475,6 +476,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): if interface_ips: ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) + oob_ip_choices.extend(ip_list) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( 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_choices.append(('NAT IPs', ip_list)) 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 # 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_ip6'].choices = [] self.fields['primary_ip6'].widget.attrs['readonly'] = True + self.fields['oob_ip'].choices = [] + self.fields['oob_ip'].widget.attrs['readonly'] = True # Rack position position = self.data.get('position') or self.initial.get('position') diff --git a/netbox/dcim/migrations/0175_device_oob_ip.py b/netbox/dcim/migrations/0175_device_oob_ip.py new file mode 100644 index 000000000..bf6a88ba8 --- /dev/null +++ b/netbox/dcim/migrations/0175_device_oob_ip.py @@ -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', + ), + ), + ] diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py new file mode 100644 index 000000000..b570ddbd5 --- /dev/null +++ b/netbox/dcim/migrations/0176_device_component_counters.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py new file mode 100644 index 000000000..66d1460d9 --- /dev/null +++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py new file mode 100644 index 000000000..e3ade1344 --- /dev/null +++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py @@ -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 + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 0355d7028..7d669bca0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -12,6 +12,7 @@ from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface +from utilities.tracking import TrackingModelMixin from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, RearPort, @@ -32,7 +33,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9f6837b92..62f26776f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar +from utilities.tracking import TrackingModelMixin from wireless.choices import * from wireless.utils import get_channel_attr @@ -269,7 +270,7 @@ class PathEndpoint(models.Model): # Console components # -class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): +class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin): """ 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}) -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. """ @@ -319,7 +320,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): # 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. """ @@ -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. """ @@ -537,7 +538,7 @@ class BaseInterface(models.Model): 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. """ @@ -888,7 +889,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd # Pass-through ports # -class FrontPort(ModularComponentModel, CabledObjectModel): +class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ 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. """ @@ -990,7 +991,7 @@ class RearPort(ModularComponentModel, CabledObjectModel): # Bays # -class ModuleBay(ComponentModel): +class ModuleBay(ComponentModel, TrackingModelMixin): """ 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}) -class DeviceBay(ComponentModel): +class DeviceBay(ComponentModel, TrackingModelMixin): """ 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]) -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. InventoryItems are used only for inventory purposes. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ece02105c..4aba73fde 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -21,7 +21,8 @@ from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel 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 .mixins import WeightMixin @@ -128,6 +129,48 @@ class DeviceType(PrimaryModel, WeightMixin): 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( to='extras.ImageAttachment' ) @@ -469,7 +512,7 @@ def update_interface_bridges(device, interface_templates, module=None): 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, 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, 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( to='virtualization.Cluster', on_delete=models.SET_NULL, @@ -639,6 +690,48 @@ class Device(PrimaryModel, ConfigContextModel): 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 contacts = GenericRelation( to='tenancy.ContactAssignment' @@ -774,7 +867,7 @@ class Device(PrimaryModel, ConfigContextModel): except DeviceType.DoesNotExist: pass - # Validate primary IP addresses + # Validate primary & OOB IP addresses vc_interfaces = self.vc_interfaces(if_master=False) if self.primary_ip4: if self.primary_ip4.family != 4: @@ -802,6 +895,15 @@ class Device(PrimaryModel, ConfigContextModel): raise ValidationError({ '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 if hasattr(self, 'device_type') and self.platform: @@ -1147,6 +1249,12 @@ class VirtualChassis(PrimaryModel): blank=True ) + # Counter fields + member_count = CounterCacheField( + to_model='dcim.Device', + to_field='virtual_chassis' + ) + class Meta: ordering = ['name'] verbose_name_plural = 'virtual chassis' diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index e4202c4f6..c892b9ea9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,11 +1,11 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables -from dcim import models 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 tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from .template_code import * __all__ = ( @@ -215,6 +215,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name=_('IPv6 Address') ) + oob_ip = tables.Column( + linkify=True, + verbose_name='OOB IP' + ) cluster = tables.Column( verbose_name=_('Cluster'), linkify=True @@ -247,6 +251,36 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( 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): model = models.Device @@ -254,8 +288,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', - 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', + 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f26b21772..10ae0a522 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,5 +1,6 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext as _ from dcim import models from netbox.tables import NetBoxTable, columns @@ -87,11 +88,6 @@ class DeviceTypeTable(NetBoxTable): is_full_depth = columns.BooleanColumn( verbose_name=_('Full Depth') ) - instance_count = columns.LinkedCountColumn( - viewname='dcim:device_list', - url_params={'device_type_id': 'pk'}, - verbose_name=_('Instances') - ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -107,12 +103,48 @@ class DeviceTypeTable(NetBoxTable): template_code=WEIGHT, 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): model = models.DeviceType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', - 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', + 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', + 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 008db382a..8611c136d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -951,7 +951,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_consoleports' tab = ViewTab( label=_('Console Ports'), - badge=lambda obj: obj.consoleporttemplates.count(), + badge=lambda obj: obj.console_port_template_count, permission='dcim.view_consoleporttemplate', weight=550, hide_if_empty=True @@ -966,7 +966,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_consoleserverports' tab = ViewTab( label=_('Console Server Ports'), - badge=lambda obj: obj.consoleserverporttemplates.count(), + badge=lambda obj: obj.console_server_port_template_count, permission='dcim.view_consoleserverporttemplate', weight=560, hide_if_empty=True @@ -981,7 +981,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_powerports' tab = ViewTab( label=_('Power Ports'), - badge=lambda obj: obj.powerporttemplates.count(), + badge=lambda obj: obj.power_port_template_count, permission='dcim.view_powerporttemplate', weight=570, hide_if_empty=True @@ -996,7 +996,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_poweroutlets' tab = ViewTab( label=_('Power Outlets'), - badge=lambda obj: obj.poweroutlettemplates.count(), + badge=lambda obj: obj.power_outlet_template_count, permission='dcim.view_poweroutlettemplate', weight=580, hide_if_empty=True @@ -1011,7 +1011,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_interfaces' tab = ViewTab( label=_('Interfaces'), - badge=lambda obj: obj.interfacetemplates.count(), + badge=lambda obj: obj.interface_template_count, permission='dcim.view_interfacetemplate', weight=520, hide_if_empty=True @@ -1026,7 +1026,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_frontports' tab = ViewTab( label=_('Front Ports'), - badge=lambda obj: obj.frontporttemplates.count(), + badge=lambda obj: obj.front_port_template_count, permission='dcim.view_frontporttemplate', weight=530, hide_if_empty=True @@ -1041,7 +1041,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_rearports' tab = ViewTab( label=_('Rear Ports'), - badge=lambda obj: obj.rearporttemplates.count(), + badge=lambda obj: obj.rear_port_template_count, permission='dcim.view_rearporttemplate', weight=540, hide_if_empty=True @@ -1056,7 +1056,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_modulebays' tab = ViewTab( label=_('Module Bays'), - badge=lambda obj: obj.modulebaytemplates.count(), + badge=lambda obj: obj.module_bay_template_count, permission='dcim.view_modulebaytemplate', weight=510, hide_if_empty=True @@ -1071,7 +1071,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_devicebays' tab = ViewTab( label=_('Device Bays'), - badge=lambda obj: obj.devicebaytemplates.count(), + badge=lambda obj: obj.device_bay_template_count, permission='dcim.view_devicebaytemplate', weight=500, hide_if_empty=True @@ -1086,7 +1086,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_inventoryitems' tab = ViewTab( label=_('Inventory Items'), - badge=lambda obj: obj.inventoryitemtemplates.count(), + badge=lambda obj: obj.inventory_item_template_count, permission='dcim.view_invenotryitemtemplate', weight=590, hide_if_empty=True @@ -1876,7 +1876,7 @@ class DeviceConsolePortsView(DeviceComponentsView): template_name = 'dcim/device/consoleports.html', tab = ViewTab( label=_('Console Ports'), - badge=lambda obj: obj.consoleports.count(), + badge=lambda obj: obj.console_port_count, permission='dcim.view_consoleport', weight=550, hide_if_empty=True @@ -1891,7 +1891,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView): template_name = 'dcim/device/consoleserverports.html' tab = ViewTab( label=_('Console Server Ports'), - badge=lambda obj: obj.consoleserverports.count(), + badge=lambda obj: obj.console_server_port_count, permission='dcim.view_consoleserverport', weight=560, hide_if_empty=True @@ -1906,7 +1906,7 @@ class DevicePowerPortsView(DeviceComponentsView): template_name = 'dcim/device/powerports.html' tab = ViewTab( label=_('Power Ports'), - badge=lambda obj: obj.powerports.count(), + badge=lambda obj: obj.power_port_count, permission='dcim.view_powerport', weight=570, hide_if_empty=True @@ -1921,7 +1921,7 @@ class DevicePowerOutletsView(DeviceComponentsView): template_name = 'dcim/device/poweroutlets.html' tab = ViewTab( label=_('Power Outlets'), - badge=lambda obj: obj.poweroutlets.count(), + badge=lambda obj: obj.power_outlet_count, permission='dcim.view_poweroutlet', weight=580, hide_if_empty=True @@ -1957,7 +1957,7 @@ class DeviceFrontPortsView(DeviceComponentsView): template_name = 'dcim/device/frontports.html' tab = ViewTab( label=_('Front Ports'), - badge=lambda obj: obj.frontports.count(), + badge=lambda obj: obj.front_port_count, permission='dcim.view_frontport', weight=530, hide_if_empty=True @@ -1972,7 +1972,7 @@ class DeviceRearPortsView(DeviceComponentsView): template_name = 'dcim/device/rearports.html' tab = ViewTab( label=_('Rear Ports'), - badge=lambda obj: obj.rearports.count(), + badge=lambda obj: obj.rear_port_count, permission='dcim.view_rearport', weight=540, hide_if_empty=True @@ -1987,7 +1987,7 @@ class DeviceModuleBaysView(DeviceComponentsView): template_name = 'dcim/device/modulebays.html' tab = ViewTab( label=_('Module Bays'), - badge=lambda obj: obj.modulebays.count(), + badge=lambda obj: obj.module_bay_count, permission='dcim.view_modulebay', weight=510, hide_if_empty=True @@ -2002,7 +2002,7 @@ class DeviceDeviceBaysView(DeviceComponentsView): template_name = 'dcim/device/devicebays.html' tab = ViewTab( label=_('Device Bays'), - badge=lambda obj: obj.devicebays.count(), + badge=lambda obj: obj.device_bay_count, permission='dcim.view_devicebay', weight=500, hide_if_empty=True @@ -2017,7 +2017,7 @@ class DeviceInventoryView(DeviceComponentsView): template_name = 'dcim/device/inventory.html' tab = ViewTab( label=_('Inventory Items'), - badge=lambda obj: obj.inventoryitems.count(), + badge=lambda obj: obj.inventory_item_count, permission='dcim.view_inventoryitem', weight=590, hide_if_empty=True @@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() def get_extra_context(self, request, instance): - # Get assigned VDC's + # Get assigned VDCs vdc_table = tables.VirtualDeviceContextTable( data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), - exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', - 'created', 'last_updated', 'actions', ), + exclude=( + 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags', + 'created', 'last_updated', 'actions', + ), orderable=False ) @@ -3225,9 +3227,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): # class VirtualChassisListView(generic.ObjectListView): - queryset = VirtualChassis.objects.annotate( - member_count=count_related(Device, 'virtual_chassis') - ) + queryset = VirtualChassis.objects.all() table = tables.VirtualChassisTable filterset = filtersets.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index acfdcf1e3..49b256b0a 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -261,7 +261,7 @@ class BookmarkTestCase( def _get_url(self, action, instance=None): if action == 'list': - return reverse('users:bookmarks') + return reverse('account:bookmarks') return super()._get_url(action, instance) def test_list_objects_anonymous(self): diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 00dcf8422..a5d6eb084 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -849,6 +849,24 @@ class IPAddress(PrimaryModel): return self.address.version 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): """ Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 8555f5e67..2a985c294 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs): @receiver(pre_delete, sender=IPAddress) def clear_primary_ip(instance, **kwargs): """ - When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it - was a primary IP. + When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP. """ field_name = f'primary_ip{instance.family}' - device = Device.objects.filter(**{field_name: instance}).first() - if device: + if device := Device.objects.filter(**{field_name: instance}).first(): device.save() - virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() - if virtualmachine: + if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first(): 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() diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 21ca0087b..23dcfb985 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -8,6 +8,7 @@ from netbox.models.features import * from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet + __all__ = ( 'ChangeLoggedModel', 'NestedGroupModel', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 45de28f2b..7e5d26186 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -353,7 +353,7 @@ ADMIN_MENU = Menu( icon_class='mdi mdi-account-multiple', groups=( MenuGroup( - label=_('Users'), + label=_('Authentication'), items=( # Proxy model for auth.User MenuItem( @@ -399,6 +399,7 @@ ADMIN_MENU = Menu( ) ) ), + get_model_item('users', 'token', _('API Tokens')), get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), ), ), diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 23b9ad4cb..21a869001 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -21,6 +21,7 @@ class Registry(dict): # Initialize the global registry registry = Registry({ + 'counter_fields': collections.defaultdict(dict), 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), 'model_features': dict(), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7d2da2996..da58b0dd6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -469,6 +469,7 @@ EXEMPT_EXCLUDE_MODELS = ( ('auth', 'group'), ('auth', 'user'), ('users', 'objectpermission'), + ('users', 'token'), ) # All URLs starting with a string listed here are exempt from login enforcement diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 4162fd382..e44e9e08e 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -32,10 +32,13 @@ _patterns = [ path('extras/', include('extras.urls')), path('ipam/', include('ipam.urls')), path('tenancy/', include('tenancy.urls')), - path('user/', include('users.urls')), + path('users/', include('users.urls')), path('virtualization/', include('virtualization.urls')), path('wireless/', include('wireless.urls')), + # Current user views + path('user/', include('users.account_urls')), + # HTMX views path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index c81bb5a3c..4d1e3dc08 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -239,6 +239,17 @@ {% endif %} + + Out-of-band IP + + {% if object.oob_ip %} + {{ object.oob_ip.address.ip }} + {% copy_content "oob_ip" %} + {% else %} + {{ ''|placeholder }} + {% endif %} + + {% if object.cluster %} Cluster diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 2dbe1e3c5..4029f5026 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -68,6 +68,7 @@ {% if object.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} + {% render_field form.oob_ip %} {% endif %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index d0fba3ca2..62b4f3dc2 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -31,6 +31,16 @@ Description {{ object.description|placeholder }} + + Members + + {% if object.member_count %} + {{ object.member_count }} + {% else %} + {{ object.member_count }} + {% endif %} + + diff --git a/netbox/templates/inc/profile_button.html b/netbox/templates/inc/profile_button.html index 932b91275..c21b59e3b 100644 --- a/netbox/templates/inc/profile_button.html +++ b/netbox/templates/inc/profile_button.html @@ -19,22 +19,22 @@ {% endif %}
  • - + Profile
  • - + Bookmarks
  • - + Preferences
  • - + API Tokens
  • diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index e58ac736f..a3c55d76e 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -96,6 +96,14 @@ {% endfor %} + + Primary IP + {% checkmark object.is_primary_ip %} + + + OOB IP + {% checkmark object.is_oob_ip %} + diff --git a/netbox/templates/users/account/api_token.html b/netbox/templates/users/account/api_token.html deleted file mode 100644 index 7fd6f064d..000000000 --- a/netbox/templates/users/account/api_token.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'generic/object.html' %} -{% load form_helpers %} -{% load helpers %} -{% load plugins %} - -{% block content %} -
    -
    - {% if not settings.ALLOW_TOKEN_RETRIEVAL %} - - {% endif %} -
    -
    Token
    -
    - - - - - - - - - - - - - - - - - - - - - -
    Key -
    - {% copy_content "token_id" %} -
    -
    {{ key }}
    -
    Description{{ object.description|placeholder }}
    User{{ object.user }}
    Created{{ object.created|annotated_date }}
    Expires - {% if object.expires %} - {{ object.expires|annotated_date }} - {% else %} - Never - {% endif %} -
    -
    -
    - -
    -
    -{% endblock %} diff --git a/netbox/templates/users/account/base.html b/netbox/templates/users/account/base.html index f492f89ec..6c1e9f028 100644 --- a/netbox/templates/users/account/base.html +++ b/netbox/templates/users/account/base.html @@ -4,21 +4,21 @@ {% block tabs %} {% endblock %} diff --git a/netbox/templates/users/account/bookmarks.html b/netbox/templates/users/account/bookmarks.html index fa3c28c7c..3867e7cdb 100644 --- a/netbox/templates/users/account/bookmarks.html +++ b/netbox/templates/users/account/bookmarks.html @@ -9,7 +9,7 @@
    {% csrf_token %} - + {# Table #}
    diff --git a/netbox/templates/users/account/password.html b/netbox/templates/users/account/password.html index dcdd19e29..f820c4eff 100644 --- a/netbox/templates/users/account/password.html +++ b/netbox/templates/users/account/password.html @@ -13,7 +13,7 @@ {% render_field form.new_password2 %}
    - Cancel + Cancel
    diff --git a/netbox/templates/users/account/preferences.html b/netbox/templates/users/account/preferences.html index 59cca302c..0fdafb6f5 100644 --- a/netbox/templates/users/account/preferences.html +++ b/netbox/templates/users/account/preferences.html @@ -79,7 +79,7 @@
    - Cancel + Cancel
    diff --git a/netbox/templates/users/account/token.html b/netbox/templates/users/account/token.html new file mode 100644 index 000000000..d83e13ff5 --- /dev/null +++ b/netbox/templates/users/account/token.html @@ -0,0 +1,69 @@ +{% extends 'generic/object.html' %} +{% load form_helpers %} +{% load helpers %} +{% load i18n %} +{% load plugins %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block title %}{% trans "Token" %} {{ object }}{% endblock %} + +{% block subtitle %}{% endblock %} + +{% block content %} +
    +
    + {% if key and not settings.ALLOW_TOKEN_RETRIEVAL %} + + {% endif %} +
    +
    {% trans "Token" %}
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {% trans "Key" %} + {% if key %} +
    + {% copy_content "token_id" %} +
    +
    {{ key }}
    + {% else %} + {{ object.partial }} + {% endif %} +
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "Write enabled" %}{% checkmark object.write_enabled %}
    {% trans "Created" %}{{ object.created|annotated_date }}
    {% trans "Expires" %}{{ object.expires|placeholder }}
    {% trans "Last used" %}{{ object.last_used|placeholder }}
    {% trans "Allowed IPs" %}{{ object.allowed_ips|join:", "|placeholder }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/users/account/api_tokens.html b/netbox/templates/users/account/token_list.html similarity index 82% rename from netbox/templates/users/account/api_tokens.html rename to netbox/templates/users/account/token_list.html index 25f5f02e6..9865cbe7c 100644 --- a/netbox/templates/users/account/api_tokens.html +++ b/netbox/templates/users/account/token_list.html @@ -2,12 +2,12 @@ {% load helpers %} {% load render_table from django_tables2 %} -{% block title %}API Tokens{% endblock %} +{% block title %}My API Tokens{% endblock %} {% block content %}
    - + Add a Token
    diff --git a/netbox/templates/users/token.html b/netbox/templates/users/token.html new file mode 100644 index 000000000..0fa8c572e --- /dev/null +++ b/netbox/templates/users/token.html @@ -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 %} +
    +
    +
    +
    {% trans "Token" %}
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {% trans "Key" %}{% if settings.ALLOW_TOKEN_RETRIEVAL %}{{ object.key }}{% else %}{{ object.partial }}{% endif %}
    {% trans "User" %} + {{ object.user }} +
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "Write enabled" %}{% checkmark object.write_enabled %}
    {% trans "Created" %}{{ object.created|annotated_date }}
    {% trans "Expires" %}{{ object.expires|placeholder }}
    {% trans "Last used" %}{{ object.last_used|placeholder }}
    {% trans "Allowed IPs" %}{{ object.allowed_ips|join:", "|placeholder }}
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/users/account_urls.py b/netbox/users/account_urls.py new file mode 100644 index 000000000..bcb031003 --- /dev/null +++ b/netbox/users/account_urls.py @@ -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//', include(get_model_urls('users', 'usertoken'))), + +] diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 316346c50..bc7bf7ab2 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -1,11 +1,6 @@ from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User -from users.models import ObjectPermission, Token -from . import filters, forms, inlines - - # # 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 admin.site.unregister(Group) 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" diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py deleted file mode 100644 index 7db6a124c..000000000 --- a/netbox/users/admin/forms.py +++ /dev/null @@ -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 diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index a4e9a9fbc..0f590e012 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -10,6 +10,7 @@ from users.models import ObjectPermission, Token __all__ = ( 'GroupFilterSet', 'ObjectPermissionFilterSet', + 'TokenFilterSet', 'UserFilterSet', ) diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index db40283ba..0e29109a4 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -1,13 +1,17 @@ from django import forms +from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ +from ipam.formfields import IPNetworkFormField +from ipam.validators import prefix_validator from users.models import * -from utilities.forms import BootstrapMixin -from utilities.forms.widgets import BulkEditNullBooleanSelect +from utilities.forms import BootstrapMixin, BulkEditForm +from utilities.forms.widgets import BulkEditNullBooleanSelect, DateTimePicker __all__ = ( 'ObjectPermissionBulkEditForm', 'UserBulkEditForm', + 'TokenBulkEditForm', ) @@ -70,3 +74,38 @@ class ObjectPermissionBulkEditForm(BootstrapMixin, forms.Form): (None, ('enabled', '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', + ) diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py index 25f779044..d1f03ff3c 100644 --- a/netbox/users/forms/bulk_import.py +++ b/netbox/users/forms/bulk_import.py @@ -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 + __all__ = ( 'GroupImportForm', 'UserImportForm', + 'TokenImportForm', ) @@ -30,3 +34,15 @@ class UserImportForm(CSVModelForm): self.instance.set_password(self.cleaned_data.get('password')) 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',) diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index eca76dea4..ff56cbc4c 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -1,4 +1,7 @@ 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.models import Group 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 utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.fields import DynamicModelMultipleChoiceField +from utilities.forms.widgets import DateTimePicker __all__ = ( 'GroupFilterForm', 'ObjectPermissionFilterForm', 'UserFilterForm', + 'TokenFilterForm', ) @@ -109,3 +114,33 @@ class ObjectPermissionFilterForm(NetBoxModelFilterSetForm): ), 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() + ) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 43b95893a..6ca050110 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -20,11 +20,13 @@ from utilities.permissions import qs_filter_from_constraints from utilities.utils import flatten_dict __all__ = ( + 'UserTokenForm', 'GroupForm', 'ObjectPermissionForm', 'TokenForm', 'UserConfigForm', '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( label=_('Key'), required=False, @@ -117,8 +119,10 @@ class TokenForm(BootstrapMixin, forms.ModelForm): base_field=IPNetworkFormField(validators=[prefix_validator]), required=False, label=_('Allowed IPs'), - help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' - 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64'), + help_text=_( + 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64' + ), ) class Meta: @@ -138,6 +142,24 @@ class TokenForm(BootstrapMixin, forms.ModelForm): 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): password = forms.CharField( label=_('Password'), diff --git a/netbox/users/migrations/0005_usertoken.py b/netbox/users/migrations/0005_usertoken.py new file mode 100644 index 000000000..c6aef0590 --- /dev/null +++ b/netbox/users/migrations/0005_usertoken.py @@ -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',), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index a8060dd63..0c95559ff 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -26,6 +26,7 @@ __all__ = ( 'ObjectPermission', 'Token', 'UserConfig', + 'UserToken', ) @@ -273,13 +274,20 @@ class Token(models.Model): blank=True, null=True, verbose_name='Allowed IPs', - help_text=_('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"'), + help_text=_( + '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): return self.key if settings.ALLOW_TOKEN_RETRIEVAL else self.partial + def get_absolute_url(self): + return reverse('users:token', args=[self.pk]) + @property def partial(self): return f'**********************************{self.key[-6:]}' if self.key else '' @@ -314,6 +322,18 @@ class Token(models.Model): 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 # diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 741a4b024..3ef885399 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -1,8 +1,8 @@ import django_tables2 as tables +from django.utils.translation import gettext as _ from netbox.tables import NetBoxTable, columns -from users.models import NetBoxGroup, NetBoxUser, ObjectPermission -from .models import Token +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token, UserToken __all__ = ( '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( - template_code=TOKEN + verbose_name=_('Key'), + template_code=TOKEN, ) 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( + verbose_name=_('Allowed IPs'), template_code=ALLOWED_IPS ) actions = TokenActionsColumn( @@ -49,10 +60,26 @@ class TokenTable(NetBoxTable): 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): model = Token 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', ) diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index ca62f474e..2997052eb 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from users.models import * -from utilities.testing import ViewTestCases +from utilities.testing import ViewTestCases, create_test_user class UserTestCase( @@ -149,3 +149,53 @@ class ObjectPermissionTestCase( cls.bulk_edit_data = { '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', + } diff --git a/netbox/users/urls.py b/netbox/users/urls.py index ca331d144..210d8a2c7 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -6,14 +6,13 @@ from . import views app_name = 'users' 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.TokenListView.as_view(), name='token_list'), - path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), - path('api-tokens//', include(get_model_urls('users', 'token'))), + # Tokens + path('tokens/', views.TokenListView.as_view(), name='token_list'), + path('tokens/add/', views.TokenEditView.as_view(), name='token_add'), + path('tokens/import/', views.TokenBulkImportView.as_view(), name='token_import'), + path('tokens/edit/', views.TokenBulkEditView.as_view(), name='token_bulk_edit'), + path('tokens/delete/', views.TokenBulkDeleteView.as_view(), name='token_bulk_delete'), + path('tokens//', include(get_model_urls('users', 'token'))), # Users path('users/', views.UserListView.as_view(), name='netboxuser_list'), diff --git a/netbox/users/views.py b/netbox/users/views.py index 99635b514..3796d9af1 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -24,7 +24,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.views import register_model_view 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() messages.success(request, "Your preferences have been updated.") - return redirect('users:preferences') + return redirect('account:preferences') return render(request, self.template_name, { 'form': form, @@ -208,7 +208,7 @@ class ChangePasswordView(LoginRequiredMixin, View): # LDAP users cannot change their password here if getattr(request.user, 'ldap_username', None): 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) @@ -223,7 +223,7 @@ class ChangePasswordView(LoginRequiredMixin, View): form.save() update_session_auth_hash(request, form.user) messages.success(request, "Your password has been changed successfully.") - return redirect('users:profile') + return redirect('account:profile') return render(request, self.template_name, { '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): - - tokens = Token.objects.filter(user=request.user) - table = tables.TokenTable(tokens) + tokens = UserToken.objects.filter(user=request.user) + table = tables.UserTokenTable(tokens) table.configure(request) - return render(request, 'users/account/api_tokens.html', { + return render(request, 'users/account/token_list.html', { 'tokens': tokens, 'active_tab': 'api-tokens', 'table': table, }) -@register_model_view(Token, 'edit') -class TokenEditView(LoginRequiredMixin, View): +@register_model_view(UserToken) +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): - 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: - token = Token(user=request.user) - - form = forms.TokenForm(instance=token) + token = UserToken(user=request.user) + form = forms.UserTokenForm(instance=token) return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('users:token_list'), + 'return_url': reverse('account:usertoken_list'), }) def post(self, request, pk=None): - if pk: - token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) - form = forms.TokenForm(request.POST, instance=token) + token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) + form = forms.UserTokenForm(request.POST, instance=token) else: - token = Token(user=request.user) - form = forms.TokenForm(request.POST) + token = UserToken(user=request.user) + form = forms.UserTokenForm(request.POST) if form.is_valid(): - token = form.save(commit=False) token.user = request.user token.save() @@ -304,7 +312,7 @@ class TokenEditView(LoginRequiredMixin, View): messages.success(request, msg) 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, 'key': token.key, 'return_url': reverse('users:token_list'), @@ -312,53 +320,91 @@ class TokenEditView(LoginRequiredMixin, View): elif '_addanother' in request.POST: return redirect(request.path) else: - return redirect('users:token_list') + return redirect('account:usertoken_list') return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('users:token_list'), + 'return_url': reverse('account:usertoken_list'), 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL }) -@register_model_view(Token, 'delete') -class TokenDeleteView(LoginRequiredMixin, View): +@register_model_view(UserToken, 'delete') +class UserTokenDeleteView(LoginRequiredMixin, View): def get(self, request, 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) + token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) return render(request, 'generic/object_delete.html', { 'object': token, - 'form': form, - 'return_url': reverse('users:token_list'), + 'form': ConfirmationForm(), + 'return_url': reverse('account:usertoken_list'), }) def post(self, request, 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 = ConfirmationForm(request.POST) + if form.is_valid(): token.delete() messages.success(request, "Token deleted") - return redirect('users:token_list') + return redirect('account:usertoken_list') return render(request, 'generic/object_delete.html', { 'object': token, '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 # - class UserListView(generic.ObjectListView): queryset = NetBoxUser.objects.all() filterset = filtersets.UserFilterSet @@ -413,7 +459,6 @@ class UserBulkDeleteView(generic.BulkDeleteView): # Groups # - class GroupListView(generic.ObjectListView): queryset = NetBoxGroup.objects.annotate(users_count=Count('user')) filterset = filtersets.GroupFilterSet @@ -448,11 +493,11 @@ class GroupBulkDeleteView(generic.BulkDeleteView): filterset = filtersets.GroupFilterSet table = tables.GroupTable + # # ObjectPermissions # - class ObjectPermissionListView(generic.ObjectListView): queryset = ObjectPermission.objects.all() filterset = filtersets.ObjectPermissionFilterSet diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py new file mode 100644 index 000000000..ee6865ca2 --- /dev/null +++ b/netbox/utilities/counters.py @@ -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}' + ) diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 8934e4ad6..ca1342df7 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -2,6 +2,7 @@ from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models +from django.utils.translation import gettext_lazy as _ from utilities.ordering import naturalize from .forms.widgets import ColorSelect @@ -9,6 +10,7 @@ from .validators import ColorValidator __all__ = ( 'ColorField', + 'CounterCacheField', 'NaturalOrderingField', 'NullableCharField', 'RestrictedGenericForeignKey', @@ -143,3 +145,43 @@ class RestrictedGenericForeignKey(GenericForeignKey): self.name, 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 diff --git a/netbox/utilities/management/__init__.py b/netbox/utilities/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/management/commands/calculate_cached_counts.py b/netbox/utilities/management/commands/calculate_cached_counts.py new file mode 100644 index 000000000..62354797c --- /dev/null +++ b/netbox/utilities/management/commands/calculate_cached_counts.py @@ -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.')) diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py new file mode 100644 index 000000000..e9561c91b --- /dev/null +++ b/netbox/utilities/tests/test_counters.py @@ -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) diff --git a/netbox/utilities/tracking.py b/netbox/utilities/tracking.py new file mode 100644 index 000000000..88945615b --- /dev/null +++ b/netbox/utilities/tracking.py @@ -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) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index f72215b98..693bb362f 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -80,12 +80,15 @@ class VirtualMachineSerializer(NetBoxModelSerializer): primary_ip4 = 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: model = VirtualMachine fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', '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 = [] @@ -98,6 +101,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'interface_count', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 1b6b110df..8db943ea1 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -6,3 +6,8 @@ class VirtualizationConfig(AppConfig): def ready(self): from . import search + from .models import VirtualMachine + from utilities.counters import connect_counters + + # Register counters + connect_counters(VirtualMachine) diff --git a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py new file mode 100644 index 000000000..5f52d32e0 --- /dev/null +++ b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py @@ -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 + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 6e9cc5664..dbbfe49ed 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -11,9 +11,10 @@ from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config 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.query_functions import CollateAsChar +from utilities.tracking import TrackingModelMixin from virtualization.choices import * __all__ = ( @@ -120,6 +121,12 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): verbose_name='Disk (GB)' ) + # Counter fields + interface_count = CounterCacheField( + to_model='virtualization.VMInterface', + to_field='virtual_machine' + ) + # Generic relation contacts = GenericRelation( to='tenancy.ContactAssignment' @@ -222,7 +229,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): return None -class VMInterface(NetBoxModel, BaseInterface): +class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index b1d44ad02..03e3a1af6 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -1,10 +1,11 @@ import django_tables2 as tables +from django.utils.translation import gettext as _ + from dcim.tables.devices import BaseInterfaceTable +from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from virtualization.models import VirtualMachine, VMInterface -from netbox.tables import NetBoxTable, columns - __all__ = ( 'VirtualMachineTable', 'VirtualMachineVMInterfaceTable', @@ -70,6 +71,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) tags = columns.TagColumn( url_name='virtualization:virtualmachine_list' ) + interface_count = tables.Column( + verbose_name=_('Interfaces') + ) class Meta(NetBoxTable.Meta): model = VirtualMachine diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 75e83f9e1..c56a8ade2 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -349,7 +349,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): template_name = 'virtualization/virtualmachine/interfaces.html' tab = ViewTab( label=_('Interfaces'), - badge=lambda obj: obj.interfaces.count(), + badge=lambda obj: obj.interface_count, permission='virtualization.view_vminterface', weight=500 )