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!
+ [](https://netboxlabs.com)
+
[](https://try.digitalocean.com/developer-cloud)
- [](https://metal.equinix.com/)
-
- [](https://ns1.com/)
+ [](https://ns1.com)
- [](https://sentry.io/)
+ [](https://sentry.io)
- [](https://stellar.tech/)
+ [](https://metal.equinix.com)
+
+ [](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 %}
-
{% 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' %}
-
- {% if vdcs %}
-
-
- Name
- Status
- Identifier
- Tenant
-
- {% for vdc in vdcs %}
-
- {{ vdc|linkify }}
- {% badge vdc.get_status_display bg_color=vdc.get_status_color %}
- {{ vdc.identifier|placeholder }}
- {{ vdc.tenant|linkify|placeholder }}
-
- {% endfor %}
-
- {% else %}
-
None
- {% endif %}
-
+
{% if perms.dcim.add_virtualdevicecontext %}
{% endif %}
- {% include 'inc/panels/services.html' %}
+
+
+
+ {% 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' %}
-
-{% 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' %}
-
-{% 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 @@
-
{% 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 %}
-
-
-
-
-
-
-
- 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 %}
-
-
-
+
{{ object.napalm_args|json }}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
-
{% 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 }}
-
{{ object.site }}
- {% if object.location %}
- {% for location in object.location.get_ancestors %}
-
{{ location }}
- {% endfor %}
-
{{ object.location }}
- {% 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 %}
-
-
-
-
-
- Panel
- Feed
- Status
- Type
- Utilization
-
- {% for powerfeed in power_feeds %}
-
- {{ 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 %}
- {% with power_port=powerfeed.connected_endpoints.0 %}
- {% if power_port %}
- {% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}
- {% else %}
- N/A
- {% endif %}
- {% endwith %}
-
- {% endfor %}
-
-
-
- {% endif %}
-
{% include 'inc/panels/image_attachments.html' %}
-
-
-
- {% if reservations %}
-
-
- Units
- Tenant
- Description
-
-
- {% for resv in reservations %}
-
- {{ 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 %}
-
-
- {% endfor %}
-
- {% 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 }}
+ {{ object.site }}
+ {% if object.location %}
+ {% for location in object.location.get_ancestors %}
+ {{ location }}
+ {% endfor %}
+ {{ object.location }}
+ {% 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" %}
+
+
+{% 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 %}
-
{% 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 %}
+
+
+
+
{% endif %}
- {% plugin_right_page object %}
-
-
-
-
-
{% 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 %}
-
-
-
-
-
- Locations
-
- {% if stats.location_count %}
- {{ stats.location_count }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- Racks
-
- {% if stats.rack_count %}
-
-
- {{ 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' %}
@@ -276,40 +171,13 @@
{% endif %}
-
-
-
- {% if asns %}
-
-
- ASN
- Description
-
- {% for asn in asns %}
-
- {{ asn|linkify }}
- {{ asn.description|placeholder }}
-
- {% endfor %}
-
- {% 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 %}
+
+
+
+
{% endif %}
- {% plugin_right_page object %}
-
-
-
-
-
{% 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 %}
+
+
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 @@
-
-
-
- {% if services %}
-
- {% for service in services %}
-
- {{ 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 %}
-
-
- {% endfor %}
-
- {% 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 %}
-
-
{% 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' %}
+
{% 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 %}
-
{% 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 %}
-
{% 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 %}
+
+
{% 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 @@
-
- {% 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' %}
-
-
-
- {% if perms.tenancy.add_contactgroup %}
-
- {% endif %}
-
{% plugin_right_page object %}
-
+
+ {% 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 %}
-
-
-
- {% 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 %}
-
-
-
-
-
-
-
- Group
- {{ object.group|linkify|placeholder }}
-
-
- Description
- {{ object.description|placeholder }}
-
-
-
+
+
+
+
+
+
+
+ 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 %}
-
-
-
-
-
-
-
-
-
-
-
-
-
Virtual Device Contexts
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% 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 %}
+
+
+
+
{% endif %}
- {% plugin_right_page object %}
-
-
-
-
-
{% 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 @@
-
{% 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 %}
-
{% 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' %}
+
+
+
+ {% 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 %}
+
+
+
+
{% endif %}
- {% plugin_right_page object %}
-
-
-
-
-
{% 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):