diff --git a/README.md b/README.md index f44ce725f..e14f31b56 100644 --- a/README.md +++ b/README.md @@ -67,15 +67,17 @@ complete list of requirements, see `requirements.txt`. The code is available

Thank you to our sponsors!

+ [![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com) +            [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)            - [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/) -            - [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/) + [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
- [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/) + [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)            - [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/) + [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com) +            + [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech)
diff --git a/SECURITY.md b/SECURITY.md index b389dd2b3..c434b6110 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. -If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. +If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. ### Bug Bounties diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 68a582e7f..26a2bf917 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -272,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt ## Test the Application -At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance: +At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally. + +!!! tip + Check that the Python virtual environment is still active before attempting to run the server. ```no-highlight python3 manage.py runserver 0.0.0.0:8000 --insecure diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md index 21d1f1211..1183a9123 100644 --- a/docs/installation/4-gunicorn.md +++ b/docs/installation/4-gunicorn.md @@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations, ## systemd Setup -We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon: +We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon. + +!!! warning "Check user & group assignment" + The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly. ```no-highlight sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/ diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index bf32f2d26..4fedddab2 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -2,6 +2,19 @@ ## v3.4.4 (FUTURE) +### Enhancements + +* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice +* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services + +### Bug Fixes + +* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit +* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table +* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file +* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516)) +* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names + --- ## v3.4.3 (2023-01-20) @@ -34,8 +47,9 @@ * [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields * [#11488](https://github.com/netbox-community/netbox/issues/11488) - Add missing `description` fields to several REST API serializers * [#11497](https://github.com/netbox-community/netbox/issues/11497) - Enforce `run_script` permission when executing scripts via REST API -* [#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex +* ~[#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex~ * [#11522](https://github.com/netbox-community/netbox/issues/11522) - Correct tag links under contact & tenant list views +* [#11537](https://github.com/netbox-community/netbox/issues/11537) - Remove obsolete "Connection" column from power feeds table * [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address --- diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index be0dca39c..6d0ab1834 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -4,6 +4,8 @@ ### Enhancements +* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI +* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments * [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging * [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces @@ -11,3 +13,4 @@ * [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template * [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`) +* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 477f9c1ab..b3f62d5fc 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -28,7 +28,9 @@ class CircuitTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='circuits:circuittype_list' ) - circuit_count = tables.Column( + circuit_count = columns.LinkedCountColumn( + viewname='circuits:circuit_list', + url_params={'type_id': 'pk'}, verbose_name='Circuits' ) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 021709be1..228b70bb1 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -29,6 +29,15 @@ class ProviderListView(generic.ObjectListView): class ProviderView(generic.ObjectView): queryset = Provider.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(Provider, 'edit') class ProviderEditView(generic.ObjectEditView): @@ -79,6 +88,18 @@ class ProviderNetworkListView(generic.ObjectListView): class ProviderNetworkView(generic.ObjectView): queryset = ProviderNetwork.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + ( + Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), + 'providernetwork_id', + ), + ) + + return { + 'related_models': related_models, + } + @register_model_view(ProviderNetwork, 'edit') class ProviderNetworkEditView(generic.ObjectEditView): @@ -127,6 +148,15 @@ class CircuitTypeListView(generic.ObjectListView): class CircuitTypeView(generic.ObjectView): queryset = CircuitType.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(CircuitType, 'edit') class CircuitTypeEditView(generic.ObjectEditView): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index bdbaf9f18..3f016899e 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -18,7 +18,6 @@ from .common import ModuleCommonForm __all__ = ( 'CableImportForm', - 'ChildDeviceImportForm', 'ConsolePortImportForm', 'ConsoleServerPortImportForm', 'DeviceBayImportForm', @@ -413,6 +412,18 @@ class DeviceImportForm(BaseDeviceImportForm): required=False, help_text=_('Mounted rack face') ) + parent = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text=_('Parent device (for child devices)') + ) + device_bay = CSVModelChoiceField( + queryset=DeviceBay.objects.all(), + to_field_name='name', + required=False, + help_text=_('Device bay in which this device is installed (for child devices)') + ) airflow = CSVChoiceField( choices=DeviceAirflowChoices, required=False, @@ -422,8 +433,8 @@ class DeviceImportForm(BaseDeviceImportForm): class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', - 'cluster', 'description', 'comments', 'tags', + 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', + 'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -434,6 +445,7 @@ class DeviceImportForm(BaseDeviceImportForm): # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) # Limit rack queryset by assigned site and group params = { @@ -442,6 +454,23 @@ class DeviceImportForm(BaseDeviceImportForm): } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) + # Limit device bay queryset by parent device + if parent := data.get('parent'): + params = {f"device__{self.fields['parent'].to_field_name}": parent} + self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) + + def clean(self): + super().clean() + + # Inherit site and rack from parent device + if parent := self.cleaned_data.get('parent'): + self.instance.site = parent.site + self.instance.rack = parent.rack + + # Set parent_bay reverse relationship + if device_bay := self.cleaned_data.get('device_bay'): + self.instance.parent_bay = device_bay + class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): device = CSVModelChoiceField( @@ -495,48 +524,6 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): return self.cleaned_data['replicate_components'] -class ChildDeviceImportForm(BaseDeviceImportForm): - parent = CSVModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text=_('Parent device') - ) - device_bay = CSVModelChoiceField( - queryset=DeviceBay.objects.all(), - to_field_name='name', - help_text=_('Device bay in which this device is installed') - ) - - class Meta(BaseDeviceImportForm.Meta): - fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags' - ] - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit device bay queryset by parent device - params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} - self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) - - def clean(self): - super().clean() - - # Set parent_bay reverse relationship - device_bay = self.cleaned_data.get('device_bay') - if device_bay: - self.instance.parent_bay = device_bay - - # Inherit site and rack from parent device - parent = self.cleaned_data.get('parent') - if parent: - self.instance.site = parent.site - self.instance.rack = parent.rack - - # # Device components # diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7a2ea50ba..904e96b83 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -107,6 +107,9 @@ class PlatformTable(NetBoxTable): name = tables.Column( linkify=True ) + manufacturer = tables.Column( + linkify=True + ) device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, @@ -580,7 +583,6 @@ class DeviceInterfaceTable(InterfaceTable): 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) - order_by = ('name',) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable', 'connection', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 39df7ce03..dff697588 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -34,13 +34,21 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): url_params={'manufacturer_id': 'pk'}, verbose_name='Device Types' ) - inventoryitem_count = tables.Column( + moduletype_count = columns.LinkedCountColumn( + viewname='dcim:moduletype_list', + url_params={'manufacturer_id': 'pk'}, + verbose_name='Module Types' + ) + inventoryitem_count = columns.LinkedCountColumn( + viewname='dcim:inventoryitem_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Inventory Items' ) - platform_count = tables.Column( + platform_count = columns.LinkedCountColumn( + viewname='dcim:platform_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Platforms' ) - slug = tables.Column() tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) @@ -48,11 +56,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Manufacturer fields = ( - 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', - 'tags', 'contacts', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', + 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', + 'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count', + 'description', 'slug', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index cb9aae6fd..657754017 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -19,7 +19,11 @@ __all__ = ( class RackRoleTable(NetBoxTable): name = tables.Column(linkify=True) - rack_count = tables.Column(verbose_name='Racks') + rack_count = columns.LinkedCountColumn( + viewname='dcim:rack_list', + url_params={'role_id': 'pk'}, + verbose_name='Racks' + ) color = columns.ColorColumn() tags = columns.TagColumn( url_name='dcim:rackrole_list' diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 6772f96ad..c71a0aff1 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -177,7 +177,6 @@ urlpatterns = [ path('devices/', views.DeviceListView.as_view(), name='device_list'), path('devices/add/', views.DeviceEditView.as_view(), name='device_add'), path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), - path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'), path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1fabc4bd9..d50aec73a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,9 +21,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view -from virtualization.filtersets import VirtualMachineFilterSet from virtualization.models import VirtualMachine -from virtualization.tables import VirtualMachineTable from . import filtersets, forms, tables from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES @@ -212,6 +210,18 @@ class RegionListView(generic.ObjectListView): class RegionView(generic.ObjectView): queryset = Region.objects.all() + def get_extra_context(self, request, instance): + regions = instance.get_descendants(include_self=True) + related_models = ( + (Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'), + (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(Region, 'edit') class RegionEditView(generic.ObjectEditView): @@ -276,6 +286,18 @@ class SiteGroupListView(generic.ObjectListView): class SiteGroupView(generic.ObjectView): queryset = SiteGroup.objects.all() + def get_extra_context(self, request, instance): + groups = instance.get_descendants(include_self=True) + related_models = ( + (Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), + (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(SiteGroup, 'edit') class SiteGroupEditView(generic.ObjectEditView): @@ -335,19 +357,25 @@ class SiteView(generic.ObjectView): queryset = Site.objects.prefetch_related('tenant__group') def get_extra_context(self, request, instance): - stats = { - 'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(), - 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), - 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), - 'vlangroup_count': VLANGroup.objects.restrict(request.user, 'view').filter( + related_models = ( + # DCIM + (Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), + (Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), + (Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), + # Virtualization + (VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'), + # IPAM + (Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), + (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'), + (VLANGroup.objects.restrict(request.user, 'view').filter( scope_type=ContentType.objects.get_for_model(Site), scope_id=instance.pk - ).count(), - 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(), - 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(), - 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(), - } + ), 'site_id'), + (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), + # Circuits + (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'), + ) + locations = Location.objects.add_related_count( Location.objects.all(), Rack, @@ -369,15 +397,9 @@ class SiteView(generic.ObjectView): parent_bay__isnull=True ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') - asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) - asn_count = asns.count() - - stats.update({'asn_count': asn_count}) - return { - 'stats': stats, + 'related_models': related_models, 'locations': locations, - 'asns': asns, 'nonracked_devices': nonracked_devices.order_by('-pk')[:10], 'total_nonracked_devices_count': nonracked_devices.count(), } @@ -441,9 +463,11 @@ class LocationView(generic.ObjectView): queryset = Location.objects.all() def get_extra_context(self, request, instance): - location_ids = instance.get_descendants(include_self=True).values_list('pk', flat=True) - rack_count = Rack.objects.filter(location__in=location_ids).count() - device_count = Device.objects.filter(location__in=location_ids).count() + locations = instance.get_descendants(include_self=True) + related_models = ( + (Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), + (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'), + ) nonracked_devices = Device.objects.filter( location=instance, @@ -452,8 +476,7 @@ class LocationView(generic.ObjectView): ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') return { - 'rack_count': rack_count, - 'device_count': device_count, + 'related_models': related_models, 'nonracked_devices': nonracked_devices.order_by('-pk')[:10], 'total_nonracked_devices_count': nonracked_devices.count(), } @@ -518,6 +541,15 @@ class RackRoleListView(generic.ObjectListView): class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(RackRole, 'edit') class RackRoleEditView(generic.ObjectEditView): @@ -623,6 +655,11 @@ class RackView(generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): + related_models = ( + (Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'), + (PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'), + ) + # Get 0U devices located within the rack nonracked_devices = Device.objects.filter( rack=instance, @@ -639,22 +676,13 @@ class RackView(generic.ObjectView): next_rack = peer_racks.filter(_name__gt=instance._name).first() prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().first() - reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=instance) - power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=instance).prefetch_related( - 'power_panel' - ) - - device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count() - # Determine any additional parameters to pass when embedding the rack elevations svg_extra = '&'.join([ f'highlight=id:{pk}' for pk in request.GET.getlist('device') ]) return { - 'device_count': device_count, - 'reservations': reservations, - 'power_feeds': power_feeds, + 'related_models': related_models, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -662,6 +690,25 @@ class RackView(generic.ObjectView): } +@register_model_view(Rack, 'reservations') +class RackRackReservationsView(generic.ObjectChildrenView): + queryset = Rack.objects.all() + child_model = RackReservation + table = tables.RackReservationTable + filterset = filtersets.RackReservationFilterSet + template_name = 'dcim/rack/reservations.html' + tab = ViewTab( + label=_('Reservations'), + badge=lambda obj: obj.reservations.count(), + permission='dcim.view_rackreservation', + weight=510, + hide_if_empty=True + ) + + def get_children(self, request, parent): + return parent.reservations.restrict(request.user, 'view') + + @register_model_view(Rack, 'edit') class RackEditView(generic.ObjectEditView): queryset = Rack.objects.all() @@ -763,6 +810,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView): class ManufacturerListView(generic.ObjectListView): queryset = Manufacturer.objects.annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), + moduletype_count=count_related(ModuleType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') ) @@ -776,20 +824,15 @@ class ManufacturerView(generic.ObjectView): queryset = Manufacturer.objects.all() def get_extra_context(self, request, instance): - device_types = DeviceType.objects.restrict(request.user, 'view').filter( - manufacturer=instance - ) - module_types = ModuleType.objects.restrict(request.user, 'view').filter( - manufacturer=instance - ) - inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( - manufacturer=instance + related_models = ( + (DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), + (ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), + (InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), + (Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'), ) return { - 'devicetype_count': device_types.count(), - 'inventoryitem_count': inventory_items.count(), - 'moduletype_count': module_types.count(), + 'related_models': related_models, } @@ -812,7 +855,10 @@ class ManufacturerBulkImportView(generic.BulkImportView): class ManufacturerBulkEditView(generic.BulkEditView): queryset = Manufacturer.objects.annotate( - devicetype_count=count_related(DeviceType, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer'), + moduletype_count=count_related(ModuleType, 'manufacturer'), + inventoryitem_count=count_related(InventoryItem, 'manufacturer'), + platform_count=count_related(Platform, 'manufacturer') ) filterset = filtersets.ManufacturerFilterSet table = tables.ManufacturerTable @@ -821,7 +867,10 @@ class ManufacturerBulkEditView(generic.BulkEditView): class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( - devicetype_count=count_related(DeviceType, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer'), + moduletype_count=count_related(ModuleType, 'manufacturer'), + inventoryitem_count=count_related(InventoryItem, 'manufacturer'), + platform_count=count_related(Platform, 'manufacturer') ) table = tables.ManufacturerTable @@ -844,10 +893,12 @@ class DeviceTypeView(generic.ObjectView): queryset = DeviceType.objects.all() def get_extra_context(self, request, instance): - instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() + related_models = ( + (Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'), + ) return { - 'instance_count': instance_count, + 'related_models': related_models, } @@ -1082,10 +1133,12 @@ class ModuleTypeView(generic.ObjectView): queryset = ModuleType.objects.all() def get_extra_context(self, request, instance): - instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count() + related_models = ( + (Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'), + ) return { - 'instance_count': instance_count, + 'related_models': related_models, } @@ -1640,41 +1693,15 @@ class DeviceRoleListView(generic.ObjectListView): class DeviceRoleView(generic.ObjectView): queryset = DeviceRole.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Device.objects.restrict(request.user, 'view').filter(device_role=instance), 'role_id'), + (VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), + ) -@register_model_view(DeviceRole, 'devices', path='devices') -class DeviceRoleDevicesView(generic.ObjectChildrenView): - queryset = DeviceRole.objects.all() - child_model = Device - table = tables.DeviceTable - filterset = filtersets.DeviceFilterSet - template_name = 'dcim/devicerole/devices.html' - tab = ViewTab( - label=_('Devices'), - badge=lambda obj: obj.devices.count(), - permission='dcim.view_device', - weight=400 - ) - - def get_children(self, request, parent): - return Device.objects.restrict(request.user, 'view').filter(device_role=parent) - - -@register_model_view(DeviceRole, 'virtual_machines', path='virtual-machines') -class DeviceRoleVirtualMachinesView(generic.ObjectChildrenView): - queryset = DeviceRole.objects.all() - child_model = VirtualMachine - table = VirtualMachineTable - filterset = VirtualMachineFilterSet - template_name = 'dcim/devicerole/virtual_machines.html' - tab = ViewTab( - label=_('Virtual machines'), - badge=lambda obj: obj.virtual_machines.count(), - permission='virtualization.view_virtualmachine', - weight=500 - ) - - def get_children(self, request, parent): - return VirtualMachine.objects.restrict(request.user, 'view').filter(role=parent) + return { + 'related_models': related_models, + } @register_model_view(DeviceRole, 'edit') @@ -1731,16 +1758,13 @@ class PlatformView(generic.ObjectView): queryset = Platform.objects.all() def get_extra_context(self, request, instance): - devices = Device.objects.restrict(request.user, 'view').filter( - platform=instance - ) - virtual_machines = VirtualMachine.objects.restrict(request.user, 'view').filter( - platform=instance + related_models = ( + (Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), + (VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'), ) return { - 'device_count': devices.count(), - 'virtualmachine_count': virtual_machines.count() + 'related_models': related_models, } @@ -1798,14 +1822,7 @@ class DeviceView(generic.ObjectView): else: vc_members = [] - services = Service.objects.restrict(request.user, 'view').filter(device=instance) - vdcs = VirtualDeviceContext.objects.restrict(request.user, 'view').filter(device=instance).prefetch_related( - 'tenant' - ) - return { - 'services': services, - 'vdcs': vdcs, 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}' } @@ -1994,22 +2011,15 @@ class DeviceBulkImportView(generic.BulkImportView): queryset = Device.objects.all() model_form = forms.DeviceImportForm table = tables.DeviceImportTable - template_name = 'dcim/device_import.html' - - -class ChildDeviceBulkImportView(generic.BulkImportView): - queryset = Device.objects.all() - model_form = forms.ChildDeviceImportForm - table = tables.DeviceImportTable - template_name = 'dcim/device_import_child.html' def save_object(self, object_form, request): obj = object_form.save() - # Save the reverse relation to the parent device bay - device_bay = obj.parent_bay - device_bay.installed_device = obj - device_bay.save() + # For child devices, save the reverse relation to the parent device bay + if getattr(obj, 'parent_bay', None): + device_bay = obj.parent_bay + device_bay.installed_device = obj + device_bay.save() return obj @@ -2113,6 +2123,21 @@ class ModuleListView(generic.ObjectListView): class ModuleView(generic.ObjectView): queryset = Module.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + (RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(Module, 'edit') class ModuleEditView(generic.ObjectEditView): @@ -3435,6 +3460,15 @@ class PowerPanelListView(generic.ObjectListView): class PowerPanelView(generic.ObjectView): queryset = PowerPanel.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(PowerPanel, 'edit') class PowerPanelEditView(generic.ObjectEditView): @@ -3536,6 +3570,15 @@ class VirtualDeviceContextListView(generic.ObjectListView): class VirtualDeviceContextView(generic.ObjectView): queryset = VirtualDeviceContext.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(VirtualDeviceContext, 'edit') class VirtualDeviceContextEditView(generic.ObjectEditView): diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 14b033bcd..4842c0654 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -273,10 +273,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge 'choices': "Choices may be set only for custom selection fields." }) - # A selection field must have at least two choices defined - if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2: + # Selection fields must have at least one choice defined + if self.type in ( + CustomFieldTypeChoices.TYPE_SELECT, + CustomFieldTypeChoices.TYPE_MULTISELECT + ) and not self.choices: raise ValidationError({ - 'choices': "Selection fields must specify at least two choices." + 'choices': "Selection fields must specify at least one choice." }) # A selection field's default (if any) must be present in its available choices diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 29e725507..81a607eec 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -101,6 +101,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): 'content_types': ['dcim.site'], 'name': 'cf6', 'type': 'select', + 'choices': ['A', 'B', 'C'] }, ] bulk_update_data = { diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index c30064ff1..d069eed27 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -923,6 +923,18 @@ class ServiceFilterSet(NetBoxModelFilterSet): to_field_name='name', label=_('Virtual machine (name)'), ) + ipaddress_id = django_filters.ModelMultipleChoiceFilter( + field_name='ipaddresses', + queryset=IPAddress.objects.all(), + label=_('IP address (ID)'), + ) + ipaddress = django_filters.ModelMultipleChoiceFilter( + field_name='ipaddresses__address', + queryset=IPAddress.objects.all(), + to_field_name='address', + label=_('IP address'), + ) + port = NumericArrayFilter( field_name='ports', lookup_expr='contains' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index a2b06080a..711009a7e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1420,6 +1420,19 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + interface = Interface.objects.create( + device=devices[0], + name='eth0', + type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + interface_ct = ContentType.objects.get_for_model(Interface).pk + ip_addresses = ( + IPAddress(address='192.0.2.1/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk), + IPAddress(address='192.0.2.2/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk), + IPAddress(address='192.0.2.3/24', assigned_object_type_id=interface_ct, assigned_object_id=interface.pk), + ) + IPAddress.objects.bulk_create(ip_addresses) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') @@ -1439,6 +1452,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]), ) Service.objects.bulk_create(services) + services[0].ipaddresses.add(ip_addresses[0]) + services[1].ipaddresses.add(ip_addresses[1]) + services[2].ipaddresses.add(ip_addresses[2]) def test_name(self): params = {'name': ['Service 1', 'Service 2']} @@ -1470,6 +1486,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_ipaddress(self): + ips = IPAddress.objects.all()[:2] + params = {'ipaddress_id': [ips[0].pk, ips[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = L2VPN.objects.all() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 9741be66b..c80ca7d74 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -37,8 +37,10 @@ class VRFView(generic.ObjectView): queryset = VRF.objects.all() def get_extra_context(self, request, instance): - prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count() - ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count() + related_models = ( + (Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), + (IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'), + ) import_targets_table = tables.RouteTargetTable( instance.import_targets.all(), @@ -50,8 +52,7 @@ class VRFView(generic.ObjectView): ) return { - 'prefix_count': prefix_count, - 'ipaddress_count': ipaddress_count, + 'related_models': related_models, 'import_targets_table': import_targets_table, 'export_targets_table': export_targets_table, } @@ -102,21 +103,6 @@ class RouteTargetListView(generic.ObjectListView): class RouteTargetView(generic.ObjectView): queryset = RouteTarget.objects.all() - def get_extra_context(self, request, instance): - importing_vrfs_table = tables.VRFTable( - instance.importing_vrfs.all(), - orderable=False - ) - exporting_vrfs_table = tables.VRFTable( - instance.exporting_vrfs.all(), - orderable=False - ) - - return { - 'importing_vrfs_table': importing_vrfs_table, - 'exporting_vrfs_table': exporting_vrfs_table, - } - @register_model_view(RouteTarget, 'edit') class RouteTargetEditView(generic.ObjectEditView): @@ -165,6 +151,15 @@ class RIRListView(generic.ObjectListView): class RIRView(generic.ObjectView): queryset = RIR.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(RIR, 'edit') class RIREditView(generic.ObjectEditView): @@ -219,12 +214,13 @@ class ASNView(generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - sites = instance.sites.restrict(request.user, 'view') - providers = instance.providers.restrict(request.user, 'view') + related_models = ( + (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'), + ) return { - 'sites_count': sites.count(), - 'providers_count': providers.count(), + 'related_models': related_models, } @@ -368,6 +364,17 @@ class RoleListView(generic.ObjectListView): class RoleView(generic.ObjectView): queryset = Role.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), + (IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), + (VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(Role, 'edit') class RoleEditView(generic.ObjectEditView): @@ -694,28 +701,10 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) - # Find services belonging to the IP - service_filter = Q(ipaddresses=instance) - - # Find services listening on all IPs on the assigned device/vm - try: - if instance.assigned_object and instance.assigned_object.parent_object: - parent_object = instance.assigned_object.parent_object - - if isinstance(parent_object, VirtualMachine): - service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) - elif isinstance(parent_object, Device): - service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) - except AttributeError: - pass - - services = Service.objects.restrict(request.user, 'view').filter(service_filter) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'related_ips_table': related_ips_table, - 'services': services, } @@ -839,11 +828,15 @@ class VLANGroupView(generic.ObjectView): queryset = VLANGroup.objects.all() def get_extra_context(self, request, instance): + related_models = ( + (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), + ) + + # TODO: Replace with embedded table vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)), 'tenant', 'site', 'role', ).order_by('vid') - vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',)) @@ -852,7 +845,7 @@ class VLANGroupView(generic.ObjectView): vlans_table.configure(request) return { - 'vlans_count': vlans_count, + 'related_models': related_models, 'vlans_table': vlans_table, } diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index d7e226c04..5fe81b1f5 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -1,21 +1,17 @@ import logging -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError -from django.http import Http404 +from rest_framework import mixins as drf_mixins from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import GenericViewSet -from extras.models import ExportTemplate -from netbox.api.exceptions import SerializerNotFound -from netbox.constants import NESTED_SERIALIZER_PREFIX -from utilities.api import get_serializer_for_model from utilities.exceptions import AbortRequest -from .mixins import * +from . import mixins __all__ = ( + 'NetBoxReadOnlyModelViewSet', 'NetBoxModelViewSet', ) @@ -30,13 +26,47 @@ HTTP_ACTIONS = { } -class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet): +class BaseViewSet(GenericViewSet): + """ + Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions. + """ + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + # Restrict the view's QuerySet to allow only the permitted objects + if request.user.is_authenticated: + if action := HTTP_ACTIONS[request.method]: + self.queryset = self.queryset.restrict(request.user, action) + + +class NetBoxReadOnlyModelViewSet( + mixins.BriefModeMixin, + mixins.CustomFieldsMixin, + mixins.ExportTemplatesMixin, + drf_mixins.RetrieveModelMixin, + drf_mixins.ListModelMixin, + BaseViewSet +): + pass + + +class NetBoxModelViewSet( + mixins.BulkUpdateModelMixin, + mixins.BulkDestroyModelMixin, + mixins.ObjectValidationMixin, + mixins.BriefModeMixin, + mixins.CustomFieldsMixin, + mixins.ExportTemplatesMixin, + drf_mixins.CreateModelMixin, + drf_mixins.RetrieveModelMixin, + drf_mixins.UpdateModelMixin, + drf_mixins.DestroyModelMixin, + drf_mixins.ListModelMixin, + BaseViewSet +): """ Extend DRF's ModelViewSet to support bulk update and delete functions. """ - brief = False - brief_prefetch_fields = [] - def get_object_with_snapshot(self): """ Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to @@ -48,71 +78,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali return obj def get_serializer(self, *args, **kwargs): - # If a list of objects has been provided, initialize the serializer with many=True if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True return super().get_serializer(*args, **kwargs) - def get_serializer_class(self): - logger = logging.getLogger('netbox.api.views.ModelViewSet') - - # If using 'brief' mode, find and return the nested serializer for this model, if one exists - if self.brief: - logger.debug("Request is for 'brief' format; initializing nested serializer") - try: - serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX) - logger.debug(f"Using serializer {serializer}") - return serializer - except SerializerNotFound: - logger.debug(f"Nested serializer for {self.queryset.model} not found!") - - # Fall back to the hard-coded serializer class - logger.debug(f"Using serializer {self.serializer_class}") - return self.serializer_class - - def get_serializer_context(self): - """ - For models which support custom fields, populate the `custom_fields` context. - """ - context = super().get_serializer_context() - - if hasattr(self.queryset.model, 'custom_fields'): - content_type = ContentType.objects.get_for_model(self.queryset.model) - context.update({ - 'custom_fields': content_type.custom_fields.all(), - }) - - return context - - def get_queryset(self): - # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any) - if self.brief: - return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields) - - return super().get_queryset() - - def initialize_request(self, request, *args, **kwargs): - # Check if brief=True has been passed - if request.method == 'GET' and request.GET.get('brief'): - self.brief = True - - return super().initialize_request(request, *args, **kwargs) - - def initial(self, request, *args, **kwargs): - super().initial(request, *args, **kwargs) - - if not request.user.is_authenticated: - return - - # Restrict the view's QuerySet to allow only the permitted objects - action = HTTP_ACTIONS[request.method] - if action: - self.queryset = self.queryset.restrict(request.user, action) - def dispatch(self, request, *args, **kwargs): - logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') try: return super().dispatch(request, *args, **kwargs) @@ -136,21 +109,11 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali **kwargs ) - def list(self, request, *args, **kwargs): - # Overrides ListModelMixin to allow processing ExportTemplates. - if 'export' in request.GET: - content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) - et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() - if et is None: - raise Http404 - queryset = self.filter_queryset(self.get_queryset()) - return et.render_to_response(queryset) - - return super().list(request, *args, **kwargs) + # Creates def perform_create(self, serializer): model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') logger.info(f"Creating new {model._meta.verbose_name}") # Enforce object-level permissions on save() @@ -161,6 +124,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali except ObjectDoesNotExist: raise PermissionDenied() + # Updates + def update(self, request, *args, **kwargs): # Hotwire get_object() to ensure we save a pre-change snapshot self.get_object = self.get_object_with_snapshot @@ -168,7 +133,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali def perform_update(self, serializer): model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") # Enforce object-level permissions on save() @@ -179,6 +144,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali except ObjectDoesNotExist: raise PermissionDenied() + # Deletes + def destroy(self, request, *args, **kwargs): # Hotwire get_object() to ensure we save a pre-change snapshot self.get_object = self.get_object_with_snapshot @@ -186,7 +153,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali def perform_destroy(self, instance): model = self.queryset.model - logger = logging.getLogger('netbox.api.views.ModelViewSet') + logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index b47c88a4e..8b629bbc6 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -1,17 +1,99 @@ +import logging + +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction +from django.http import Http404 from rest_framework import status from rest_framework.response import Response +from extras.models import ExportTemplate +from netbox.api.exceptions import SerializerNotFound from netbox.api.serializers import BulkOperationSerializer +from netbox.constants import NESTED_SERIALIZER_PREFIX +from utilities.api import get_serializer_for_model __all__ = ( + 'BriefModeMixin', 'BulkUpdateModelMixin', + 'CustomFieldsMixin', + 'ExportTemplatesMixin', 'BulkDestroyModelMixin', 'ObjectValidationMixin', ) +class BriefModeMixin: + """ + Enables brief mode support, so that the client can invoke a model's nested serializer by passing e.g. + GET /api/dcim/sites/?brief=True + """ + brief = False + brief_prefetch_fields = [] + + def initialize_request(self, request, *args, **kwargs): + # Annotate whether brief mode is active + self.brief = request.method == 'GET' and request.GET.get('brief') + + return super().initialize_request(request, *args, **kwargs) + + def get_serializer_class(self): + logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') + + # If using 'brief' mode, find and return the nested serializer for this model, if one exists + if self.brief: + logger.debug("Request is for 'brief' format; initializing nested serializer") + try: + return get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX) + except SerializerNotFound: + logger.debug( + f"Nested serializer for {self.queryset.model} not found! Using serializer {self.serializer_class}" + ) + + return self.serializer_class + + def get_queryset(self): + qs = super().get_queryset() + + # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any) + if self.brief: + return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields) + + return qs + + +class CustomFieldsMixin: + """ + For models which support custom fields, populate the `custom_fields` context. + """ + def get_serializer_context(self): + context = super().get_serializer_context() + + if hasattr(self.queryset.model, 'custom_fields'): + content_type = ContentType.objects.get_for_model(self.queryset.model) + context.update({ + 'custom_fields': content_type.custom_fields.all(), + }) + + return context + + +class ExportTemplatesMixin: + """ + Enable ExportTemplate support for list views. + """ + def list(self, request, *args, **kwargs): + if 'export' in request.GET: + content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) + et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() + if et is None: + raise Http404 + queryset = self.filter_queryset(self.get_queryset()) + return et.render_to_response(queryset) + + return super().list(request, *args, **kwargs) + + class BulkUpdateModelMixin: """ Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index a8b41d139..83c238e0f 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -122,7 +122,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): def _extend_nullable_fields(self): nullable_custom_fields = [ - name for name, customfield in self.custom_fields.items() if not customfield.required + name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE) ] self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 09a35489d..83a81690f 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -46,6 +46,7 @@ ORGANIZATION_MENU = Menu( get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactrole', _('Contact Roles')), + get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]), ), ), ), diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index 4cefecc87..39c1f1541 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -28,12 +28,6 @@ Description {{ object.description|placeholder }} - - Circuits - - {{ object.circuits.count }} - - @@ -41,19 +35,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Circuits
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 8cd7e59fb..3973d2867 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -37,21 +37,16 @@ Description {{ object.description|placeholder }} - - Circuits - - {{ object.circuits.count }} - - {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 29c31ab47..f478058ec 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -37,12 +37,13 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index aa31db97c..3c2cc6299 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -157,28 +157,10 @@ {% include 'inc/panels/comments.html' %}
Virtual Device Contexts
-
- {% if vdcs %} - - - - - - - - {% for vdc in vdcs %} - - - - - - - {% endfor %} -
NameStatusIdentifierTenant
{{ vdc|linkify }}{% badge vdc.get_status_display bg_color=vdc.get_status_color %}{{ vdc.identifier|placeholder }}{{ vdc.tenant|linkify|placeholder }}
- {% else %} -
None
- {% endif %} -
+
{% if perms.dcim.add_virtualdevicecontext %}
{% endif %} - {% include 'inc/panels/services.html' %} +
+
Services
+
+ {% if perms.ipam.add_service %} +
+ {% endif %} +
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% if object.rack and object.position %} diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html deleted file mode 100644 index b30de60c2..000000000 --- a/netbox/templates/dcim/device_import.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'generic/bulk_import.html' %} - -{% block tabs %} - {% include 'dcim/inc/device_import_header.html' %} -{% endblock %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html deleted file mode 100644 index d0dc72b61..000000000 --- a/netbox/templates/dcim/device_import_child.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'generic/bulk_import.html' %} - -{% block tabs %} - {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} -{% endblock %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 2e0794582..bc01dbdb7 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -42,22 +42,6 @@ VM Role {% checkmark object.vm_role %} - - Devices - - {{ object.devices.count }} - - - - Virtual Machines - - {% if object.vm_role %} - {{ object.virtual_machines.count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - @@ -65,6 +49,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/devicerole/devices.html b/netbox/templates/dcim/devicerole/devices.html deleted file mode 100644 index 18d8910fe..000000000 --- a/netbox/templates/dcim/devicerole/devices.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'dcim/devicerole.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal='DeviceTable_config' %} -
- {% csrf_token %} -
-
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox/templates/dcim/devicerole/virtual_machines.html b/netbox/templates/dcim/devicerole/virtual_machines.html deleted file mode 100644 index 0f428974f..000000000 --- a/netbox/templates/dcim/devicerole/virtual_machines.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'dcim/devicerole.html' %} -{% load helpers %} -{% load render_table from django_tables2 %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal='VirtualMachineTable_config' %} -
- {% csrf_token %} -
-
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} \ No newline at end of file diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 930390a56..73c82ddae 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -85,18 +85,15 @@ {% endif %} - - Instances - {{ instance_count }} - + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/inc/device_import_header.html b/netbox/templates/dcim/inc/device_import_header.html deleted file mode 100644 index 97e849c2a..000000000 --- a/netbox/templates/dcim/inc/device_import_header.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 9eee5f9db..193d93f9a 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -56,33 +56,15 @@ {{ object.tenant|linkify|placeholder }} - - Racks - - {% if rack_count %} -
- - - -
- {% endif %} - {{ rack_count }} - - - - Devices - - {{ device_count }} - - {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/contacts.html' %} {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/image_attachments.html' %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 260f8e39a..a60b3503c 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -42,24 +42,6 @@ Description {{ object.description|placeholder }} - - Device types - - {{ devicetype_count }} - - - - Module types - - {{ moduletype_count }} - - - - Inventory Items - - {{ inventoryitem_count }} - -
@@ -67,6 +49,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} @@ -74,13 +57,6 @@
-
-
Device Types
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 78d5a1a05..e46bc65f5 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -81,104 +81,14 @@ - {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} - -
-
-
Components
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Interfaces - {% with component_count=object.interfaces.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Console Ports - {% with component_count=object.consoleports.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Console Server Ports - {% with component_count=object.consoleserverports.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Power Ports - {% with component_count=object.powerports.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Power Outlets - {% with component_count=object.poweroutlets.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Front Ports - {% with component_count=object.frontports.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
Rear Ports - {% with component_count=object.rearports.count %} - {% if component_count %} - {{ component_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} -
-
-
- {% plugin_right_page object %} +
+
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index fd0148c2f..8929678b7 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -36,19 +36,16 @@ {% endif %} - - Instances - {{ instance_count }} -
- {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index f134ac649..17a313d82 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -43,46 +43,26 @@ NAPALM Driver {{ object.napalm_driver|placeholder }} - - Devices - - {{ device_count }} - - - - Virtual Machines - - {{ virtualmachine_count }} - - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} - -
-
- NAPALM Arguments -
+
NAPALM Arguments
{{ object.napalm_args|json }}
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Devices
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index c73e33b13..af08f3023 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -38,11 +38,12 @@ {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index e2cb1597e..9cb046b4e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,31 +1,9 @@ -{% extends 'generic/object.html' %} +{% extends 'dcim/rack/base.html' %} {% load buttons %} {% load helpers %} {% load static %} {% load plugins %} -{% block title %}Rack {{ object }}{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} - - {% if object.location %} - {% for location in object.location.get_ancestors %} - - {% endfor %} - - {% endif %} -{% endblock %} - -{% block extra_controls %} - - Previous - - - Next - -{% endblock %} - {% block content %}
@@ -90,12 +68,6 @@ Asset Tag {{ object.asset_tag|placeholder }} - - Devices - - {{ device_count }} - - Space Utilization {% utilization_graph object.get_utilization %} @@ -192,90 +164,7 @@ {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} - {% if power_feeds %} -
-
- Power Feeds -
-
- - - - - - - - - {% for powerfeed in power_feeds %} - - - - - - {% with power_port=powerfeed.connected_endpoints.0 %} - {% if power_port %} - - {% else %} - - {% endif %} - {% endwith %} - - {% endfor %} -
PanelFeedStatusTypeUtilization
{{ powerfeed.power_panel|linkify }}{{ powerfeed|linkify }}{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}N/A
-
-
- {% endif %} - {% include 'inc/panels/image_attachments.html' %} -
-
- Reservations -
-
- {% if reservations %} - - - - - - - - {% for resv in reservations %} - - - - - - - {% endfor %} -
UnitsTenantDescription
{{ resv|linkify:"unit_list" }}{{ resv.tenant|linkify|placeholder }} - {{ resv.description }}
- {{ resv.user }} · {{ resv.created|annotated_date }} -
- {% if perms.dcim.change_rackreservation %} - - - - {% endif %} - {% if perms.dcim.delete_rackreservation %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} -
- {% if perms.dcim.add_rackreservation %} - - {% endif %} -
{% plugin_left_page object %}
@@ -300,6 +189,7 @@
+ {% include 'inc/panels/related_objects.html' %} {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rack/base.html b/netbox/templates/dcim/rack/base.html new file mode 100644 index 000000000..8ac7b70d0 --- /dev/null +++ b/netbox/templates/dcim/rack/base.html @@ -0,0 +1,23 @@ +{% extends 'generic/object.html' %} + +{% block title %}Rack {{ object }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + + {% if object.location %} + {% for location in object.location.get_ancestors %} + + {% endfor %} + + {% endif %} +{% endblock %} + +{% block extra_controls %} + + Previous + + + Next + +{% endblock %} diff --git a/netbox/templates/dcim/rack/reservations.html b/netbox/templates/dcim/rack/reservations.html new file mode 100644 index 000000000..fb357e592 --- /dev/null +++ b/netbox/templates/dcim/rack/reservations.html @@ -0,0 +1,43 @@ +{% extends 'dcim/rack/base.html' %} +{% load helpers %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="RackReservationTable_config" %} + +
+ {% csrf_token %} + +
+
+ {% include 'htmx/table.html' %} +
+
+ +
+
+ {% if 'bulk_edit' in actions %} + + {% endif %} + {% if 'bulk_delete' in actions %} + + {% endif %} +
+ {% if perms.dcim.add_rackreservation %} + + {% endif %} +
+
+{% endblock %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 0f229e910..2d2945025 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -34,12 +34,6 @@   - - Racks - - {{ object.racks.count }} - -
@@ -47,19 +41,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Racks
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 2f33d5e15..85587e4b5 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -37,21 +37,21 @@ Parent {{ object.parent|linkify|placeholder }} - - Sites - - {{ object.sites.count }} - - {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/contacts.html' %} + {% plugin_right_page object %} +
+ +
+
Child Regions
{% endif %}
- {% plugin_right_page object %} -
-
-
-
-
-
Sites
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a4ee4180f..d6de8f3cb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -126,112 +126,7 @@ {% plugin_left_page object %}
-
-
Related Objects
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Locations - {% if stats.location_count %} - {{ stats.location_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Racks - {% if stats.rack_count %} - - {% else %} - {{ ''|placeholder }} - {% endif %} -
Devices - {% if stats.device_count %} - {{ stats.device_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Virtual Machines - {% if stats.vm_count %} - {{ stats.vm_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Prefixes - {% if stats.prefix_count %} - {{ stats.prefix_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
VLAN Groups - {% if stats.vlangroup_count %} - {{ stats.vlangroup_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
VLANs - {% if stats.vlan_count %} - {{ stats.vlan_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
ASNs - {% if stats.asn_count %} - {{ stats.asn_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Circuits - {% if stats.circuit_count %} - {{ stats.circuit_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
-
- {% include 'dcim/inc/nonracked_devices.html' %} + {% include 'inc/panels/related_objects.html' with filter_name='site_id' %} {% include 'inc/panels/contacts.html' %}
Locations
@@ -276,40 +171,13 @@
{% endif %}
-
-
ASNs
-
- {% if asns %} - - - - - - {% for asn in asns %} - - - - - {% endfor %} -
ASNDescription
{{ asn|linkify }}{{ asn.description|placeholder }}
- {% else %} - None - {% endif %} -
- {% if perms.ipam.add_asn %} - - {% endif %} -
{% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
+ {% include 'dcim/inc/nonracked_devices.html' %} {% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 5c117e147..2cf8e7168 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -37,12 +37,6 @@ Parent {{ object.parent|linkify|placeholder }} - - Sites - - {{ object.sites.count }} - - @@ -52,6 +46,12 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} + {% plugin_right_page object %} +
+ +
+
Child Groups
{% endif %}
- {% plugin_right_page object %} -
-
-
-
-
-
Sites
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/virtualdevicecontext.html b/netbox/templates/dcim/virtualdevicecontext.html index ee30db19e..d6e3e0c63 100644 --- a/netbox/templates/dcim/virtualdevicecontext.html +++ b/netbox/templates/dcim/virtualdevicecontext.html @@ -59,10 +59,11 @@
{% plugin_left_page object %} + {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/inc/panels/related_objects.html b/netbox/templates/inc/panels/related_objects.html new file mode 100644 index 000000000..fdc439ac6 --- /dev/null +++ b/netbox/templates/inc/panels/related_objects.html @@ -0,0 +1,21 @@ +{% load helpers %} + +
+
Related Objects
+ +
diff --git a/netbox/templates/inc/panels/services.html b/netbox/templates/inc/panels/services.html deleted file mode 100644 index b7109f497..000000000 --- a/netbox/templates/inc/panels/services.html +++ /dev/null @@ -1,50 +0,0 @@ -
-
Services
-
- {% if services %} - - {% for service in services %} - - - - - - - - - {% endfor %} -
{{ service|linkify:"name" }}{{ service.get_protocol_display }}{{ service.port_list }} - {% for ip in service.ipaddresses.all %} - {{ ip.address.ip }}
- {% empty %} - All IPs - {% endfor %} -
{{ service.description }} - - - - {% if perms.ipam.change_service %} - - - - {% endif %} - {% if perms.ipam.delete_service %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} -
- {% if perms.ipam.add_service %} - {% with object|meta:"model_name" as object_type %} - - {% endwith %} - {% endif %} -
diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 26903b71c..a54a0aee5 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -39,54 +39,21 @@ Description {{ object.description|placeholder }} - - Sites - - {% if sites_count %} - {{ sites_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - - - Providers - - {% if providers_count %} - {{ providers_count }} - {% else %} - {{ ''|placeholder }} - {% endif %} - - {% plugin_left_page object %} + {% include 'inc/panels/tags.html' %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
-
-
Sites
-
-
-
-
Providers
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 74c1131ca..c649f1dad 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -117,14 +117,19 @@ {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %} {% endif %} {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %} - {% include 'inc/panels/services.html' %} +
+
Services
+
+
{% plugin_right_page object %} -
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index a0355b99c..35b3c6b06 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -32,32 +32,20 @@ Private {% checkmark object.is_private %} - - Aggregates - - {{ object.aggregates.count }} - - + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Aggregates
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 1018824e9..12b73c1a9 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -32,44 +32,20 @@ Weight {{ object.weight }} - - Prefixes - - {{ object.prefixes.count }} - - - - IP Ranges - - {{ object.ip_ranges.count }} - - - - VLANs - - {{ object.vlans.count }} - - + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Prefixes
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index ea7a98c97..fae9866b5 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -25,18 +25,54 @@ {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
-
- {% include 'inc/panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} -
- {% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
+
+
+
+
Importing VRFs
+
+
+
+
+
+
Exporting VRFs
+
+
+
+
+
+
+
+
Importing L2VPNs
+
+
+
+
+
+
Exporting L2VPNs
+
+
+
+
{% plugin_full_width_page object %} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 822b4a046..2917536be 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -42,12 +42,6 @@ Permitted VIDs {{ object.min_vid }} - {{ object.max_vid }} - - VLANs - - {{ vlans_count }} - -
@@ -55,6 +49,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index b53862f9e..c365efae3 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -35,25 +35,14 @@ Description {{ object.description|placeholder }} - - Prefixes - - {{ prefix_count }} - - - - IP Addresses - - {{ ipaddress_count }} - - + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index d92226137..f249a8858 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -67,19 +67,15 @@ Description {{ object.description|placeholder }} - - Assignments - {{ assignment_count }} -
- {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
+ {% include 'inc/panels/comments.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
@@ -87,10 +83,10 @@
Assignments
-
- {% render_table assignments_table 'inc/table.html' %} - {% include 'inc/paginator.html' with paginator=assignments_table.paginator page=assignments_table.page %} -
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index 414aea917..ca2fdaffa 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -31,12 +31,6 @@ Parent {{ object.parent|linkify|placeholder }} - - Contacts - - {{ object.contacts.count }} - - @@ -44,31 +38,25 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} -
-
Child Groups
-
- {% if perms.tenancy.add_contactgroup %} - - {% endif %} -
{% plugin_right_page object %}
-
Contacts
+
Child Groups
+ {% if perms.tenancy.add_contactgroup %} + + {% endif %}
{% plugin_full_width_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 85b78578a..bb4802423 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -22,12 +22,6 @@ Description {{ object.description|placeholder }} - - Assignments - - {{ assignment_count }} - - @@ -35,19 +29,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Assigned Contacts
-
- {% render_table contacts_table 'inc/table.html' %} - {% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %} -
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index c13145f42..da48f1ef5 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -10,127 +10,37 @@ {% endblock breadcrumbs %} {% block content %} -
-
-
-
- Tenant -
-
- - - - - - - - - -
Group{{ object.group|linkify|placeholder }}
Description{{ object.description|placeholder }}
-
+
+
+
+
Tenant
+
+ + + + + + + + + +
Group{{ object.group|linkify|placeholder }}
Description{{ object.description|placeholder }}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/contacts.html' %} - {% plugin_left_page object %} -
-
-
-
- Stats -
-
- - -
-

{{ stats.rackreservation_count }}

-

Rack reservations

-
-
-

{{ stats.location_count }}

-

Locations

-
-
-

{{ stats.device_count }}

-

Devices

-
-
-

{{ stats.vdc_count }}

-

Virtual Device Contexts

-
- - -
-

{{ stats.aggregate_count }}

-

Aggregates

-
- -
-

{{ stats.prefix_count }}

-

Prefixes

-
-
-

{{ stats.iprange_count }}

-

IP Ranges

-
-
-

{{ stats.ipaddress_count }}

-

IP addresses

-
- - - - -
-

{{ stats.circuit_count }}

-

Circuits

-
-
-

{{ stats.virtualmachine_count }}

-

Virtual machines

-
-
-

{{ stats.cluster_count }}

-

Clusters

-
-
-

{{ stats.wirelesslan_count }}

-

Wireless LANs

-
-
-

{{ stats.wirelesslink_count }}

-

Wireless Links

-
-
-
- {% plugin_right_page object %} +
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} + {% plugin_left_page object %}
-
-
+
+ {% include 'inc/panels/related_objects.html' %} + {% plugin_right_page object %} +
+
+
- {% plugin_full_width_page object %} + {% plugin_full_width_page object %}
-
+
{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index c026693c3..be9b3fbd0 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -39,12 +39,6 @@ Parent {{ object.parent|linkify|placeholder }} - - Tenants - - {{ object.tenants.count }} - -
@@ -52,7 +46,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+ +
+
Child Groups
{% endif %}
- {% plugin_right_page object %} -
-
-
-
-
-
Tenants
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 5f34a82c5..3dfef108b 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -44,10 +44,6 @@ Site {{ object.site|linkify|placeholder }} - - Virtual Machines - {{ object.virtual_machines.count }} -
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index 7d7d5a677..510433068 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -28,12 +28,6 @@ Description {{ object.description|placeholder }} - - Clusters - - {{ object.clusters.count }} - - @@ -41,6 +35,7 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} @@ -48,13 +43,6 @@
-
-
Clusters
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index 5a5379160..2881fc1da 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -41,19 +41,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
-
-
Clusters
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 9b5708486..5098a2f8f 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -144,7 +144,20 @@ - {% include 'inc/panels/services.html' %} +
+
Services
+
+ {% if perms.ipam.add_service %} + + {% endif %} +
{% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html index 27072b2b0..6351804ef 100644 --- a/netbox/templates/wireless/wirelesslangroup.html +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -37,12 +37,6 @@ Parent {{ object.parent|linkify|placeholder }} - - Wireless LANs - - {{ object.wirelesslans.count }} - - @@ -50,7 +44,13 @@ {% plugin_left_page object %}
+ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+ +
+
Child Groups
{% endif %}
- {% plugin_right_page object %} -
-
-
-
-
-
Wireless LANs
-
-
{% plugin_full_width_page object %}
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index c5d7fca0c..7f843d9a4 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,11 +1,17 @@ +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ +from extras.utils import FeatureQuery from netbox.forms import NetBoxModelFilterSetForm +from tenancy.choices import * from tenancy.models import * from tenancy.forms import ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.fields import ( + ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField, +) __all__ = ( + 'ContactAssignmentFilterForm', 'ContactFilterForm', 'ContactGroupFilterForm', 'ContactRoleFilterForm', @@ -71,3 +77,36 @@ class ContactFilterForm(NetBoxModelFilterSetForm): label=_('Group') ) tag = TagFilterField(model) + + +class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): + model = ContactAssignment + fieldsets = ( + (None, ('q', 'filter_id')), + ('Assignment', ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), + ) + content_type_id = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False, + label=_('Object type') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Group') + ) + contact_id = DynamicModelMultipleChoiceField( + queryset=Contact.objects.all(), + required=False, + label=_('Contact') + ) + role_id = DynamicModelMultipleChoiceField( + queryset=ContactRole.objects.all(), + required=False, + label=_('Role') + ) + priority = MultipleChoiceField( + choices=ContactPriorityChoices, + required=False + ) diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 3b5addaec..cb8715f70 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -47,6 +47,7 @@ urlpatterns = [ path('contacts//', include(get_model_urls('tenancy', 'contact'))), # Contact assignments + path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'), path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), path('contact-assignments//', include(get_model_urls('tenancy', 'contactassignment'))), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 23a16ec94..b7585b8d7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext as _ from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext @@ -34,6 +35,16 @@ class TenantGroupListView(generic.ObjectListView): class TenantGroupView(generic.ObjectView): queryset = TenantGroup.objects.all() + def get_extra_context(self, request, instance): + groups = instance.get_descendants(include_self=True) + related_models = ( + (Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(TenantGroup, 'edit') class TenantGroupEditView(generic.ObjectEditView): @@ -92,31 +103,36 @@ class TenantView(generic.ObjectView): queryset = Tenant.objects.all() def get_extra_context(self, request, instance): - stats = { - 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'wirelesslan_count': WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'wirelesslink_count': WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - } + related_models = [ + # DCIM + (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + # IPAM + (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + # Circuits + (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + # Virtualization + (VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + # Wireless + (WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + ] return { - 'stats': stats, + 'related_models': related_models, } @@ -171,6 +187,16 @@ class ContactGroupListView(generic.ObjectListView): class ContactGroupView(generic.ObjectView): queryset = ContactGroup.objects.all() + def get_extra_context(self, request, instance): + groups = instance.get_descendants(include_self=True) + related_models = ( + (Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(ContactGroup, 'edit') class ContactGroupEditView(generic.ObjectEditView): @@ -229,16 +255,12 @@ class ContactRoleView(generic.ObjectView): queryset = ContactRole.objects.all() def get_extra_context(self, request, instance): - contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( - role=instance + related_models = ( + (ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'), ) - contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) - contacts_table.columns.hide('role') - contacts_table.configure(request) return { - 'contacts_table': contacts_table, - 'assignment_count': ContactAssignment.objects.filter(role=instance).count(), + 'related_models': related_models, } @@ -288,19 +310,6 @@ class ContactListView(generic.ObjectListView): class ContactView(generic.ObjectView): queryset = Contact.objects.all() - def get_extra_context(self, request, instance): - contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( - contact=instance - ) - assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) - assignments_table.columns.hide('contact') - assignments_table.configure(request) - - return { - 'assignments_table': assignments_table, - 'assignment_count': ContactAssignment.objects.filter(contact=instance).count(), - } - @register_model_view(Contact, 'edit') class ContactEditView(generic.ObjectEditView): @@ -340,6 +349,13 @@ class ContactBulkDeleteView(generic.BulkDeleteView): # Contact assignments # +class ContactAssignmentListView(generic.ObjectListView): + queryset = ContactAssignment.objects.all() + filterset = filtersets.ContactAssignmentFilterSet + filterset_form = forms.ContactAssignmentFilterForm + table = tables.ContactAssignmentTable + + @register_model_view(ContactAssignment, 'edit') class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 8fbe9e8b3..0f1484887 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -19,6 +19,14 @@ COPY_BUTTON = """ """ +class TokenActionsColumn(columns.ActionsColumn): + # Subclass ActionsColumn to disregard permissions for edit & delete buttons + actions = { + 'edit': columns.ActionsItem('Edit', 'pencil', None, 'warning'), + 'delete': columns.ActionsItem('Delete', 'trash-can-outline', None, 'danger'), + } + + class TokenTable(NetBoxTable): key = columns.TemplateColumn( template_code=TOKEN @@ -32,7 +40,7 @@ class TokenTable(NetBoxTable): allowed_ips = columns.TemplateColumn( template_code=ALLOWED_IPS ) - actions = columns.ActionsColumn( + actions = TokenActionsColumn( actions=('edit', 'delete'), extra_buttons=COPY_BUTTON ) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index b6f626eb4..23c2666df 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -527,6 +527,7 @@ def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_place if type(highlight) is re.Pattern: pre, match, post = highlight.split(value, maxsplit=1) else: + highlight = re.escape(highlight) pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE) except ValueError as e: # Match not found diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index bbb46face..7feff18d5 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -10,7 +10,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service -from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable +from ipam.tables import InterfaceVLANTable from netbox.views import generic from utilities.utils import count_related from utilities.views import ViewTab, register_model_view @@ -36,17 +36,12 @@ class ClusterTypeView(generic.ObjectView): queryset = ClusterType.objects.all() def get_extra_context(self, request, instance): - clusters = Cluster.objects.restrict(request.user, 'view').filter( - type=instance - ).annotate( - device_count=count_related(Device, 'cluster'), - vm_count=count_related(VirtualMachine, 'cluster') + related_models = ( + (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'), ) - clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',)) - clusters_table.configure(request) return { - 'clusters_table': clusters_table, + 'related_models': related_models, } @@ -100,6 +95,15 @@ class ClusterGroupListView(generic.ObjectListView): class ClusterGroupView(generic.ObjectView): queryset = ClusterGroup.objects.all() + def get_extra_context(self, request, instance): + related_models = ( + (Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(ClusterGroup, 'edit') class ClusterGroupEditView(generic.ObjectEditView): @@ -323,32 +327,7 @@ class VirtualMachineListView(generic.ObjectListView): @register_model_view(VirtualMachine) class VirtualMachineView(generic.ObjectView): - queryset = VirtualMachine.objects.prefetch_related('tenant__group') - - def get_extra_context(self, request, instance): - # Interfaces - vminterfaces = VMInterface.objects.restrict(request.user, 'view').filter( - virtual_machine=instance - ).prefetch_related( - Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)) - ) - vminterface_table = tables.VirtualMachineVMInterfaceTable(vminterfaces, user=request.user, orderable=False) - if request.user.has_perm('virtualization.change_vminterface') or \ - request.user.has_perm('virtualization.delete_vminterface'): - vminterface_table.columns.show('pk') - - # Services - services = Service.objects.restrict(request.user, 'view').filter( - virtual_machine=instance - ).prefetch_related( - Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)), - 'virtual_machine' - ) - - return { - 'vminterface_table': vminterface_table, - 'services': services, - } + queryset = VirtualMachine.objects.all() @register_model_view(VirtualMachine, 'interfaces') diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 8665ed988..22b1ff15d 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -27,6 +27,16 @@ class WirelessLANGroupListView(generic.ObjectListView): class WirelessLANGroupView(generic.ObjectView): queryset = WirelessLANGroup.objects.all() + def get_extra_context(self, request, instance): + groups = instance.get_descendants(include_self=True) + related_models = ( + (WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'), + ) + + return { + 'related_models': related_models, + } + @register_model_view(WirelessLANGroup, 'edit') class WirelessLANGroupEditView(generic.ObjectEditView):