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 %} +
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 %} - | -
{% 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 }} | +
{% 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 }} | +
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/