Merge branch 'feature' into 9608-drf-spectacular2

This commit is contained in:
Arthur 2023-01-30 08:24:55 -08:00
commit b097ee25fc
74 changed files with 916 additions and 1367 deletions

View File

@ -67,15 +67,17 @@ complete list of requirements, see `requirements.txt`. The code is available
<div align="center"> <div align="center">
<h3>Thank you to our sponsors!</h3> <h3>Thank you to our sponsors!</h3>
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud) [![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/) [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
<br /> <br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/) [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/) [![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech)
</div> </div>

View File

@ -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. 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 ### Bug Bounties

View File

@ -272,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
## Test the Application ## 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 ```no-highlight
python3 manage.py runserver 0.0.0.0:8000 --insecure python3 manage.py runserver 0.0.0.0:8000 --insecure

View File

@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup ## 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 ```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/ sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/

View File

@ -2,6 +2,19 @@
## v3.4.4 (FUTURE) ## 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) ## 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 * [#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 * [#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 * [#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 * [#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 * [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
--- ---

View File

@ -4,6 +4,8 @@
### Enhancements ### 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 * [#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 * [#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 * [#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`) * [#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

View File

@ -28,7 +28,9 @@ class CircuitTypeTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:circuittype_list' 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' verbose_name='Circuits'
) )

View File

@ -29,6 +29,15 @@ class ProviderListView(generic.ObjectListView):
class ProviderView(generic.ObjectView): class ProviderView(generic.ObjectView):
queryset = Provider.objects.all() 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') @register_model_view(Provider, 'edit')
class ProviderEditView(generic.ObjectEditView): class ProviderEditView(generic.ObjectEditView):
@ -79,6 +88,18 @@ class ProviderNetworkListView(generic.ObjectListView):
class ProviderNetworkView(generic.ObjectView): class ProviderNetworkView(generic.ObjectView):
queryset = ProviderNetwork.objects.all() 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') @register_model_view(ProviderNetwork, 'edit')
class ProviderNetworkEditView(generic.ObjectEditView): class ProviderNetworkEditView(generic.ObjectEditView):
@ -127,6 +148,15 @@ class CircuitTypeListView(generic.ObjectListView):
class CircuitTypeView(generic.ObjectView): class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all() 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') @register_model_view(CircuitType, 'edit')
class CircuitTypeEditView(generic.ObjectEditView): class CircuitTypeEditView(generic.ObjectEditView):

View File

@ -18,7 +18,6 @@ from .common import ModuleCommonForm
__all__ = ( __all__ = (
'CableImportForm', 'CableImportForm',
'ChildDeviceImportForm',
'ConsolePortImportForm', 'ConsolePortImportForm',
'ConsoleServerPortImportForm', 'ConsoleServerPortImportForm',
'DeviceBayImportForm', 'DeviceBayImportForm',
@ -413,6 +412,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False, required=False,
help_text=_('Mounted rack face') 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( airflow = CSVChoiceField(
choices=DeviceAirflowChoices, choices=DeviceAirflowChoices,
required=False, required=False,
@ -422,8 +433,8 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta): class Meta(BaseDeviceImportForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority', 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
'cluster', 'description', 'comments', 'tags', 'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -434,6 +445,7 @@ class DeviceImportForm(BaseDeviceImportForm):
# Limit location queryset by assigned site # Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('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['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 # Limit rack queryset by assigned site and group
params = { params = {
@ -442,6 +454,23 @@ class DeviceImportForm(BaseDeviceImportForm):
} }
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) 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): class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
@ -495,48 +524,6 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
return self.cleaned_data['replicate_components'] 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 # Device components
# #

View File

@ -107,6 +107,9 @@ class PlatformTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
manufacturer = tables.Column(
linkify=True
)
device_count = columns.LinkedCountColumn( device_count = columns.LinkedCountColumn(
viewname='dcim:device_list', viewname='dcim:device_list',
url_params={'platform_id': 'pk'}, 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', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions', 'untagged_vlan', 'tagged_vlans', 'actions',
) )
order_by = ('name',)
default_columns = ( default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection', 'cable', 'connection',

View File

@ -34,13 +34,21 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
url_params={'manufacturer_id': 'pk'}, url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types' 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' verbose_name='Inventory Items'
) )
platform_count = tables.Column( platform_count = columns.LinkedCountColumn(
viewname='dcim:platform_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Platforms' verbose_name='Platforms'
) )
slug = tables.Column()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:manufacturer_list' url_name='dcim:manufacturer_list'
) )
@ -48,11 +56,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Manufacturer model = models.Manufacturer
fields = ( fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'tags', 'contacts', 'actions', 'created', 'last_updated', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug',
) )

View File

@ -19,7 +19,11 @@ __all__ = (
class RackRoleTable(NetBoxTable): class RackRoleTable(NetBoxTable):
name = tables.Column(linkify=True) 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() color = columns.ColorColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:rackrole_list' url_name='dcim:rackrole_list'

View File

@ -177,7 +177,6 @@ urlpatterns = [
path('devices/', views.DeviceListView.as_view(), name='device_list'), path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'), path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), 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/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'), path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),

View File

@ -21,9 +21,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES from .constants import NONCONNECTABLE_IFACE_TYPES
@ -212,6 +210,18 @@ class RegionListView(generic.ObjectListView):
class RegionView(generic.ObjectView): class RegionView(generic.ObjectView):
queryset = Region.objects.all() 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') @register_model_view(Region, 'edit')
class RegionEditView(generic.ObjectEditView): class RegionEditView(generic.ObjectEditView):
@ -276,6 +286,18 @@ class SiteGroupListView(generic.ObjectListView):
class SiteGroupView(generic.ObjectView): class SiteGroupView(generic.ObjectView):
queryset = SiteGroup.objects.all() 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') @register_model_view(SiteGroup, 'edit')
class SiteGroupEditView(generic.ObjectEditView): class SiteGroupEditView(generic.ObjectEditView):
@ -335,19 +357,25 @@ class SiteView(generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group') queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
stats = { related_models = (
'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(), # DCIM
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), (Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), (Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), (Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
'vlangroup_count': VLANGroup.objects.restrict(request.user, 'view').filter( # 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_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk scope_id=instance.pk
).count(), ), 'site_id'),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(), (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(), # Circuits
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(), (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
} )
locations = Location.objects.add_related_count( locations = Location.objects.add_related_count(
Location.objects.all(), Location.objects.all(),
Rack, Rack,
@ -369,15 +397,9 @@ class SiteView(generic.ObjectView):
parent_bay__isnull=True parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') ).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 { return {
'stats': stats, 'related_models': related_models,
'locations': locations, 'locations': locations,
'asns': asns,
'nonracked_devices': nonracked_devices.order_by('-pk')[:10], 'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
'total_nonracked_devices_count': nonracked_devices.count(), 'total_nonracked_devices_count': nonracked_devices.count(),
} }
@ -441,9 +463,11 @@ class LocationView(generic.ObjectView):
queryset = Location.objects.all() queryset = Location.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
location_ids = instance.get_descendants(include_self=True).values_list('pk', flat=True) locations = instance.get_descendants(include_self=True)
rack_count = Rack.objects.filter(location__in=location_ids).count() related_models = (
device_count = Device.objects.filter(location__in=location_ids).count() (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( nonracked_devices = Device.objects.filter(
location=instance, location=instance,
@ -452,8 +476,7 @@ class LocationView(generic.ObjectView):
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
return { return {
'rack_count': rack_count, 'related_models': related_models,
'device_count': device_count,
'nonracked_devices': nonracked_devices.order_by('-pk')[:10], 'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
'total_nonracked_devices_count': nonracked_devices.count(), 'total_nonracked_devices_count': nonracked_devices.count(),
} }
@ -518,6 +541,15 @@ class RackRoleListView(generic.ObjectListView):
class RackRoleView(generic.ObjectView): class RackRoleView(generic.ObjectView):
queryset = RackRole.objects.all() 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') @register_model_view(RackRole, 'edit')
class RackRoleEditView(generic.ObjectEditView): class RackRoleEditView(generic.ObjectEditView):
@ -623,6 +655,11 @@ class RackView(generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance): 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 # Get 0U devices located within the rack
nonracked_devices = Device.objects.filter( nonracked_devices = Device.objects.filter(
rack=instance, rack=instance,
@ -639,22 +676,13 @@ class RackView(generic.ObjectView):
next_rack = peer_racks.filter(_name__gt=instance._name).first() next_rack = peer_racks.filter(_name__gt=instance._name).first()
prev_rack = peer_racks.filter(_name__lt=instance._name).reverse().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 # Determine any additional parameters to pass when embedding the rack elevations
svg_extra = '&'.join([ svg_extra = '&'.join([
f'highlight=id:{pk}' for pk in request.GET.getlist('device') f'highlight=id:{pk}' for pk in request.GET.getlist('device')
]) ])
return { return {
'device_count': device_count, 'related_models': related_models,
'reservations': reservations,
'power_feeds': power_feeds,
'nonracked_devices': nonracked_devices, 'nonracked_devices': nonracked_devices,
'next_rack': next_rack, 'next_rack': next_rack,
'prev_rack': prev_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') @register_model_view(Rack, 'edit')
class RackEditView(generic.ObjectEditView): class RackEditView(generic.ObjectEditView):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@ -763,6 +810,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView): class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate( 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'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
) )
@ -776,20 +824,15 @@ class ManufacturerView(generic.ObjectView):
queryset = Manufacturer.objects.all() queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
device_types = DeviceType.objects.restrict(request.user, 'view').filter( related_models = (
manufacturer=instance (DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
) (ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
module_types = ModuleType.objects.restrict(request.user, 'view').filter( (InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
manufacturer=instance (Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
)
inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
manufacturer=instance
) )
return { return {
'devicetype_count': device_types.count(), 'related_models': related_models,
'inventoryitem_count': inventory_items.count(),
'moduletype_count': module_types.count(),
} }
@ -812,7 +855,10 @@ class ManufacturerBulkImportView(generic.BulkImportView):
class ManufacturerBulkEditView(generic.BulkEditView): class ManufacturerBulkEditView(generic.BulkEditView):
queryset = Manufacturer.objects.annotate( 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 filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -821,7 +867,10 @@ class ManufacturerBulkEditView(generic.BulkEditView):
class ManufacturerBulkDeleteView(generic.BulkDeleteView): class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( 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 table = tables.ManufacturerTable
@ -844,10 +893,12 @@ class DeviceTypeView(generic.ObjectView):
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance): 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 { return {
'instance_count': instance_count, 'related_models': related_models,
} }
@ -1082,10 +1133,12 @@ class ModuleTypeView(generic.ObjectView):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance): 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 { return {
'instance_count': instance_count, 'related_models': related_models,
} }
@ -1640,41 +1693,15 @@ class DeviceRoleListView(generic.ObjectListView):
class DeviceRoleView(generic.ObjectView): class DeviceRoleView(generic.ObjectView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
def get_extra_context(self, request, instance):
@register_model_view(DeviceRole, 'devices', path='devices') related_models = (
class DeviceRoleDevicesView(generic.ObjectChildrenView): (Device.objects.restrict(request.user, 'view').filter(device_role=instance), 'role_id'),
queryset = DeviceRole.objects.all() (VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
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 {
return Device.objects.restrict(request.user, 'view').filter(device_role=parent) 'related_models': related_models,
}
@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)
@register_model_view(DeviceRole, 'edit') @register_model_view(DeviceRole, 'edit')
@ -1731,16 +1758,13 @@ class PlatformView(generic.ObjectView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
devices = Device.objects.restrict(request.user, 'view').filter( related_models = (
platform=instance (Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
) (VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
virtual_machines = VirtualMachine.objects.restrict(request.user, 'view').filter(
platform=instance
) )
return { return {
'device_count': devices.count(), 'related_models': related_models,
'virtualmachine_count': virtual_machines.count()
} }
@ -1798,14 +1822,7 @@ class DeviceView(generic.ObjectView):
else: else:
vc_members = [] 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 { return {
'services': services,
'vdcs': vdcs,
'vc_members': vc_members, 'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}' 'svg_extra': f'highlight=id:{instance.pk}'
} }
@ -1994,19 +2011,12 @@ class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all() queryset = Device.objects.all()
model_form = forms.DeviceImportForm model_form = forms.DeviceImportForm
table = tables.DeviceImportTable 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): def save_object(self, object_form, request):
obj = object_form.save() obj = object_form.save()
# Save the reverse relation to the parent device bay # 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 = obj.parent_bay
device_bay.installed_device = obj device_bay.installed_device = obj
device_bay.save() device_bay.save()
@ -2113,6 +2123,21 @@ class ModuleListView(generic.ObjectListView):
class ModuleView(generic.ObjectView): class ModuleView(generic.ObjectView):
queryset = Module.objects.all() 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') @register_model_view(Module, 'edit')
class ModuleEditView(generic.ObjectEditView): class ModuleEditView(generic.ObjectEditView):
@ -3435,6 +3460,15 @@ class PowerPanelListView(generic.ObjectListView):
class PowerPanelView(generic.ObjectView): class PowerPanelView(generic.ObjectView):
queryset = PowerPanel.objects.all() 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') @register_model_view(PowerPanel, 'edit')
class PowerPanelEditView(generic.ObjectEditView): class PowerPanelEditView(generic.ObjectEditView):
@ -3536,6 +3570,15 @@ class VirtualDeviceContextListView(generic.ObjectListView):
class VirtualDeviceContextView(generic.ObjectView): class VirtualDeviceContextView(generic.ObjectView):
queryset = VirtualDeviceContext.objects.all() 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') @register_model_view(VirtualDeviceContext, 'edit')
class VirtualDeviceContextEditView(generic.ObjectEditView): class VirtualDeviceContextEditView(generic.ObjectEditView):

View File

@ -273,10 +273,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
'choices': "Choices may be set only for custom selection fields." 'choices': "Choices may be set only for custom selection fields."
}) })
# A selection field must have at least two choices defined # Selection fields must have at least one choice defined
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2: if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices:
raise ValidationError({ 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 # A selection field's default (if any) must be present in its available choices

View File

@ -101,6 +101,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
'content_types': ['dcim.site'], 'content_types': ['dcim.site'],
'name': 'cf6', 'name': 'cf6',
'type': 'select', 'type': 'select',
'choices': ['A', 'B', 'C']
}, },
] ]
bulk_update_data = { bulk_update_data = {

View File

@ -923,6 +923,18 @@ class ServiceFilterSet(NetBoxModelFilterSet):
to_field_name='name', to_field_name='name',
label=_('Virtual machine (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( port = NumericArrayFilter(
field_name='ports', field_name='ports',
lookup_expr='contains' lookup_expr='contains'

View File

@ -1420,6 +1420,19 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Device.objects.bulk_create(devices) 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') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 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(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
) )
Service.objects.bulk_create(services) 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): def test_name(self):
params = {'name': ['Service 1', 'Service 2']} params = {'name': ['Service 1', 'Service 2']}
@ -1470,6 +1486,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'virtual_machine': [vms[0].name, vms[1].name]} params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = L2VPN.objects.all() queryset = L2VPN.objects.all()

View File

@ -37,8 +37,10 @@ class VRFView(generic.ObjectView):
queryset = VRF.objects.all() queryset = VRF.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count() related_models = (
ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count() (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( import_targets_table = tables.RouteTargetTable(
instance.import_targets.all(), instance.import_targets.all(),
@ -50,8 +52,7 @@ class VRFView(generic.ObjectView):
) )
return { return {
'prefix_count': prefix_count, 'related_models': related_models,
'ipaddress_count': ipaddress_count,
'import_targets_table': import_targets_table, 'import_targets_table': import_targets_table,
'export_targets_table': export_targets_table, 'export_targets_table': export_targets_table,
} }
@ -102,21 +103,6 @@ class RouteTargetListView(generic.ObjectListView):
class RouteTargetView(generic.ObjectView): class RouteTargetView(generic.ObjectView):
queryset = RouteTarget.objects.all() 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') @register_model_view(RouteTarget, 'edit')
class RouteTargetEditView(generic.ObjectEditView): class RouteTargetEditView(generic.ObjectEditView):
@ -165,6 +151,15 @@ class RIRListView(generic.ObjectListView):
class RIRView(generic.ObjectView): class RIRView(generic.ObjectView):
queryset = RIR.objects.all() 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') @register_model_view(RIR, 'edit')
class RIREditView(generic.ObjectEditView): class RIREditView(generic.ObjectEditView):
@ -219,12 +214,13 @@ class ASNView(generic.ObjectView):
queryset = ASN.objects.all() queryset = ASN.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
sites = instance.sites.restrict(request.user, 'view') related_models = (
providers = instance.providers.restrict(request.user, 'view') (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 { return {
'sites_count': sites.count(), 'related_models': related_models,
'providers_count': providers.count(),
} }
@ -368,6 +364,17 @@ class RoleListView(generic.ObjectListView):
class RoleView(generic.ObjectView): class RoleView(generic.ObjectView):
queryset = Role.objects.all() 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') @register_model_view(Role, 'edit')
class RoleEditView(generic.ObjectEditView): class RoleEditView(generic.ObjectEditView):
@ -694,28 +701,10 @@ class IPAddressView(generic.ObjectView):
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request) 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 { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table, 'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table, 'related_ips_table': related_ips_table,
'services': services,
} }
@ -839,11 +828,15 @@ class VLANGroupView(generic.ObjectView):
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()
def get_extra_context(self, request, instance): 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( vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)), Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
'tenant', 'site', 'role', 'tenant', 'site', 'role',
).order_by('vid') ).order_by('vid')
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance) vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',)) vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
@ -852,7 +845,7 @@ class VLANGroupView(generic.ObjectView):
vlans_table.configure(request) vlans_table.configure(request)
return { return {
'vlans_count': vlans_count, 'related_models': related_models,
'vlans_table': vlans_table, 'vlans_table': vlans_table,
} }

View File

@ -1,21 +1,17 @@
import logging import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError 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.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 utilities.exceptions import AbortRequest
from .mixins import * from . import mixins
__all__ = ( __all__ = (
'NetBoxReadOnlyModelViewSet',
'NetBoxModelViewSet', '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. Extend DRF's ModelViewSet to support bulk update and delete functions.
""" """
brief = False
brief_prefetch_fields = []
def get_object_with_snapshot(self): def get_object_with_snapshot(self):
""" """
Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to 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 return obj
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True # If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list): if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True kwargs['many'] = True
return super().get_serializer(*args, **kwargs) 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): def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet') logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
try: try:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -136,21 +109,11 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
**kwargs **kwargs
) )
def list(self, request, *args, **kwargs): # Creates
# 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)
def perform_create(self, serializer): def perform_create(self, serializer):
model = self.queryset.model 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}") logger.info(f"Creating new {model._meta.verbose_name}")
# Enforce object-level permissions on save() # Enforce object-level permissions on save()
@ -161,6 +124,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
# Updates
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot # Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot self.get_object = self.get_object_with_snapshot
@ -168,7 +133,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
def perform_update(self, serializer): def perform_update(self, serializer):
model = self.queryset.model 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})") logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
# Enforce object-level permissions on save() # Enforce object-level permissions on save()
@ -179,6 +144,8 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
# Deletes
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot # Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot self.get_object = self.get_object_with_snapshot
@ -186,7 +153,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
def perform_destroy(self, instance): def perform_destroy(self, instance):
model = self.queryset.model 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})") logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
return super().perform_destroy(instance) return super().perform_destroy(instance)

View File

@ -1,17 +1,99 @@
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.http import Http404
from rest_framework import status from rest_framework import status
from rest_framework.response import Response 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.api.serializers import BulkOperationSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'BriefModeMixin',
'BulkUpdateModelMixin', 'BulkUpdateModelMixin',
'CustomFieldsMixin',
'ExportTemplatesMixin',
'BulkDestroyModelMixin', 'BulkDestroyModelMixin',
'ObjectValidationMixin', '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: class BulkUpdateModelMixin:
""" """
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

View File

@ -122,7 +122,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
def _extend_nullable_fields(self): def _extend_nullable_fields(self):
nullable_custom_fields = [ 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) self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)

View File

@ -46,6 +46,7 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contact', _('Contacts')),
get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
get_model_item('tenancy', 'contactrole', _('Contact Roles')), get_model_item('tenancy', 'contactrole', _('Contact Roles')),
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]),
), ),
), ),
), ),

View File

@ -28,12 +28,6 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Circuits</th>
<td>
<a href="{% url 'circuits:circuit_list' %}?type_id={{ object.pk }}">{{ object.circuits.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -41,19 +35,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:circuit_list' %}?type_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -37,21 +37,16 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Circuits</th>
<td>
<a href="{% url 'circuits:circuit_list' %}?provider={{ object.slug }}">{{ object.circuits.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -37,12 +37,13 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -157,28 +157,10 @@
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
<div class="card"> <div class="card">
<h5 class="card-header">Virtual Device Contexts</h5> <h5 class="card-header">Virtual Device Contexts</h5>
<div class="card-body"> <div class="card-body htmx-container table-responsive"
{% if vdcs %} hx-get="{% url 'dcim:virtualdevicecontext_list' %}?device_id={{ object.pk }}"
<table class="table table-hover"> hx-trigger="load"
<tr> ></div>
<th>Name</th>
<th>Status</th>
<th>Identifier</th>
<th>Tenant</th>
</tr>
{% for vdc in vdcs %}
<tr>
<td>{{ vdc|linkify }}</td>
<td>{% badge vdc.get_status_display bg_color=vdc.get_status_color %}</td>
<td>{{ vdc.identifier|placeholder }}</td>
<td>{{ vdc.tenant|linkify|placeholder }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
</div>
{% if perms.dcim.add_virtualdevicecontext %} {% if perms.dcim.add_virtualdevicecontext %}
<div class="card-footer text-end noprint"> <div class="card-footer text-end noprint">
<a href="{% url 'dcim:virtualdevicecontext_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary"> <a href="{% url 'dcim:virtualdevicecontext_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
@ -300,7 +282,20 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% include 'inc/panels/services.html' %} <div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:service_list' %}?device_id={{ object.pk }}"
hx-trigger="load"
></div>
{% if perms.ipam.add_service %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a service
</a>
</div>
{% endif %}
</div>
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% if object.rack and object.position %} {% if object.rack and object.position %}

View File

@ -1,5 +0,0 @@
{% extends 'generic/bulk_import.html' %}
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' %}
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends 'generic/bulk_import.html' %}
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
{% endblock %}

View File

@ -42,22 +42,6 @@
<th scope="row">VM Role</th> <th scope="row">VM Role</th>
<td>{% checkmark object.vm_role %}</td> <td>{% checkmark object.vm_role %}</td>
</tr> </tr>
<tr>
<th scope="row">Devices</th>
<td>
<a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ object.devices.count }}</a>
</td>
</tr>
<tr>
<th>Virtual Machines</th>
<td>
{% if object.vm_role %}
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ object.virtual_machines.count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -65,6 +49,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -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' %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -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' %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -85,18 +85,15 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ object.pk }}">{{ instance_count }}</a></td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -1,8 +0,0 @@
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class ="nav-link{% if not active_tab %} active{% endif %}" href="{% url 'dcim:device_import' %}">Racked Devices</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link{% if active_tab == 'child_import' %} active{% endif %}" href="{% url 'dcim:device_import_child' %}">Child Devices</a>
</li>
</ul>

View File

@ -56,33 +56,15 @@
{{ object.tenant|linkify|placeholder }} {{ object.tenant|linkify|placeholder }}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Racks</th>
<td class="position-relative">
{% if rack_count %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ object.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>
</div>
{% endif %}
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
</td>
</tr>
<tr>
<th scope="row">Devices</th>
<td>
<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}">{{ device_count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'dcim/inc/nonracked_devices.html' %} {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}

View File

@ -42,24 +42,6 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Device types</th>
<td>
<a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}">{{ devicetype_count }}</a>
</td>
</tr>
<tr>
<th scope="row">Module types</th>
<td>
<a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.pk }}">{{ moduletype_count }}</a>
</td>
</tr>
<tr>
<th scope="row">Inventory Items</th>
<td>
<a href="{% url 'dcim:inventoryitem_list' %}?manufacturer_id={{ object.pk }}">{{ inventoryitem_count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -67,6 +49,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
@ -74,13 +57,6 @@
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Device Types</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -81,103 +81,13 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> {% include 'inc/panels/related_objects.html' %}
<h5 class="card-header">Components</h5> {% include 'inc/panels/custom_fields.html' %}
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Interfaces</th>
<td>
{% with component_count=object.interfaces.count %}
{% if component_count %}
<a href="{% url 'dcim:interface_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Console Ports</th>
<td>
{% with component_count=object.consoleports.count %}
{% if component_count %}
<a href="{% url 'dcim:consoleport_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Console Server Ports</th>
<td>
{% with component_count=object.consoleserverports.count %}
{% if component_count %}
<a href="{% url 'dcim:consoleserverport_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Power Ports</th>
<td>
{% with component_count=object.powerports.count %}
{% if component_count %}
<a href="{% url 'dcim:powerport_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Power Outlets</th>
<td>
{% with component_count=object.poweroutlets.count %}
{% if component_count %}
<a href="{% url 'dcim:poweroutlet_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Front Ports</th>
<td>
{% with component_count=object.frontports.count %}
{% if component_count %}
<a href="{% url 'dcim:frontport_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
<tr>
<th scope="row">Rear Ports</th>
<td>
{% with component_count=object.rearports.count %}
{% if component_count %}
<a href="{% url 'dcim:rearport_list' %}?module_id={{ object.pk }}">{{ component_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
{% endwith %}
</td>
</tr>
</table>
</div>
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -36,19 +36,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -43,46 +43,26 @@
<th scope="row">NAPALM Driver</th> <th scope="row">NAPALM Driver</th>
<td>{{ object.napalm_driver|placeholder }}</td> <td>{{ object.napalm_driver|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Devices</th>
<td>
<a href="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}">{{ device_count }}</a>
</td>
</tr>
<tr>
<th scope="row">Virtual Machines</th>
<td>
<a href="{% url 'virtualization:virtualmachine_list' %}?platform_id={{ object.pk }}">{{ virtualmachine_count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">NAPALM Arguments</h5>
NAPALM Arguments
</h5>
<div class="card-body"> <div class="card-body">
<pre>{{ object.napalm_args|json }}</pre> <pre>{{ object.napalm_args|json }}</pre>
</div> </div>
</div> </div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Devices</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -38,6 +38,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}

View File

@ -1,31 +1,9 @@
{% extends 'generic/object.html' %} {% extends 'dcim/rack/base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load static %} {% load static %}
{% load plugins %} {% load plugins %}
{% block title %}Rack {{ object }}{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
{% if object.location %}
{% for location in object.location.get_ancestors %}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location }}</a></li>
{% endfor %}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ object.location.pk }}">{{ object.location }}</a></li>
{% endif %}
{% endblock %}
{% block extra_controls %}
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
</a>
<a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not next_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-right" aria-hidden="true"></i> Next
</a>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-12 col-xl-5"> <div class="col col-12 col-xl-5">
@ -90,12 +68,6 @@
<th scope="row">Asset Tag</th> <th scope="row">Asset Tag</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td> <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Devices</th>
<td>
<a href="{% url 'dcim:device_list' %}?rack_id={{ object.id }}">{{ device_count }}</a>
</td>
</tr>
<tr> <tr>
<th scope="row">Space Utilization</th> <th scope="row">Space Utilization</th>
<td>{% utilization_graph object.get_utilization %}</td> <td>{% utilization_graph object.get_utilization %}</td>
@ -192,90 +164,7 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% if power_feeds %}
<div class="card">
<h5 class="card-header">
Power Feeds
</h5>
<div class="card-body">
<table class="table">
<tr>
<th>Panel</th>
<th>Feed</th>
<th>Status</th>
<th>Type</th>
<th>Utilization</th>
</tr>
{% for powerfeed in power_feeds %}
<tr>
<td>{{ powerfeed.power_panel|linkify }}</td>
<td>{{ powerfeed|linkify }}</td>
<td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td>
<td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td>
{% with power_port=powerfeed.connected_endpoints.0 %}
{% if power_port %}
<td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td>
{% else %}
<td class="text-muted">N/A</td>
{% endif %}
{% endwith %}
</tr>
{% endfor %}
</table>
</div>
</div>
{% endif %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">
Reservations
</h5>
<div class="card-body">
{% if reservations %}
<table class="table table-hover">
<tr>
<th>Units</th>
<th>Tenant</th>
<th>Description</th>
<th></th>
</tr>
{% for resv in reservations %}
<tr>
<td>{{ resv|linkify:"unit_list" }}</td>
<td>{{ resv.tenant|linkify|placeholder }}</td>
<td>
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created|annotated_date }}</small>
</td>
<td class="text-end noprint">
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm" title="Edit Reservation">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.delete_rackreservation %}
<a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm" title="Delete Reservation">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
</div>
{% if perms.dcim.add_rackreservation %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:rackreservation_add' %}?rack={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
Add a Reservation
</a>
</div>
{% endif %}
</div>
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-12 col-xl-7"> <div class="col col-12 col-xl-7">
@ -300,6 +189,7 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %} {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -0,0 +1,23 @@
{% extends 'generic/object.html' %}
{% block title %}Rack {{ object }}{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
{% if object.location %}
{% for location in object.location.get_ancestors %}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location }}</a></li>
{% endfor %}
<li class="breadcrumb-item"><a href="{% url 'dcim:rack_list' %}?location_id={{ object.location.pk }}">{{ object.location }}</a></li>
{% endif %}
{% endblock %}
{% block extra_controls %}
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
</a>
<a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not next_rack %} disabled{% endif %}">
<i class="mdi mdi-chevron-right" aria-hidden="true"></i> Next
</a>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends 'dcim/rack/base.html' %}
{% load helpers %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="RackReservationTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<button type="submit" name="_edit" formaction="{% url 'dcim:rackreservation_bulk_edit' %}?return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
</button>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:rackreservation_bulk_delete' %}?return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
</button>
{% endif %}
</div>
{% if perms.dcim.add_rackreservation %}
<div class="bulk-button-group">
<a href="{% url 'dcim:rackreservation_add' %}?rack={{ object.pk }}&return_url={% url 'dcim:rack_reservations' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add reservation
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -34,12 +34,6 @@
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span> <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Racks</th>
<td>
<a href="{% url 'dcim:rack_list' %}?role_id={{ object.pk }}">{{ object.racks.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -47,19 +41,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Racks</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:rack_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -37,21 +37,21 @@
<th scope="row">Parent</th> <th scope="row">Parent</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Sites</th>
<td>
<a href="{% url 'dcim:site_list' %}?region_id={{ object.pk }}">{{ object.sites.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Child Regions</h5> <h5 class="card-header">Child Regions</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"
@ -66,18 +66,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Sites</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:site_list' %}?region_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -126,112 +126,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
<h5 class="card-header">Related Objects</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Locations</th>
<td class="text-end">
{% if stats.location_count %}
<a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}">{{ stats.location_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Racks</th>
<td class="text-end">
{% if stats.rack_count %}
<div class="dropdown">
<button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ stats.rack_count }}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}">View Racks</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rack_elevation_list' %}?site_id={{ object.pk }}">View Elevations</a></li>
</ul>
</div>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Devices</th>
<td class="text-end">
{% if stats.device_count %}
<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}">{{ stats.device_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Virtual Machines</th>
<td class="text-end">
{% if stats.vm_count %}
<a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}">{{ stats.vm_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Prefixes</th>
<td class="text-end">
{% if stats.prefix_count %}
<a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}">{{ stats.prefix_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">VLAN Groups</th>
<td class="text-end">
{% if stats.vlangroup_count %}
<a href="{% url 'ipam:vlangroup_list' %}?site={{ object.pk }}">{{ stats.vlangroup_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">VLANs</th>
<td class="text-end">
{% if stats.vlan_count %}
<a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}">{{ stats.vlan_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">ASNs</th>
<td class="text-end">
{% if stats.asn_count %}
<a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}">{{ stats.asn_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Circuits</th>
<td class="text-end">
{% if stats.circuit_count %}
<a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}">{{ stats.circuit_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
<div class="card"> <div class="card">
<h5 class="card-header">Locations</h5> <h5 class="card-header">Locations</h5>
@ -276,40 +171,13 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="card">
<h5 class="card-header">ASNs</h5>
<div class='card-body'>
{% if asns %}
<table class="table table-hover">
<tr>
<th>ASN</th>
<th>Description</th>
</tr>
{% for asn in asns %}
<tr>
<td>{{ asn|linkify }}</td>
<td>{{ asn.description|placeholder }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
{% if perms.ipam.add_asn %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:asn_add' %}?sites={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add an ASN
</a>
</div>
{% endif %}
</div>
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% include 'dcim/inc/nonracked_devices.html' %}
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -37,12 +37,6 @@
<th scope="row">Parent</th> <th scope="row">Parent</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Sites</th>
<td>
<a href="{% url 'dcim:site_list' %}?group_id={{ object.pk }}">{{ object.sites.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -52,6 +46,12 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Child Groups</h5> <h5 class="card-header">Child Groups</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"
@ -66,18 +66,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Sites</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:site_list' %}?group_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -59,10 +59,11 @@
</div> </div>
</div> </div>
{% plugin_left_page object %} {% plugin_left_page object %}
{% include 'inc/panels/tags.html' %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -0,0 +1,21 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Related Objects</h5>
<ul class="list-group list-group-flush">
{% for qs, filter_param in related_models %}
{% with viewname=qs.model|viewname:"list" %}
<a href="{% url viewname %}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
{% if count %}
<span class="badge bg-primary rounded-pill">{{ count }}</span>
{% else %}
<span class="badge bg-light rounded-pill">&mdash;</span>
{% endif %}
{% endwith %}
</a>
{% endwith %}
{% endfor %}
</ul>
</div>

View File

@ -1,50 +0,0 @@
<div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body">
{% if services %}
<table class="table table-hover">
{% for service in services %}
<tr>
<td>{{ service|linkify:"name" }}</td>
<td>{{ service.get_protocol_display }}</td>
<td>{{ service.port_list }}</td>
<td>
{% for ip in service.ipaddresses.all %}
<a href="{{ ip.get_absolute_url }}">{{ ip.address.ip }}</a><br />
{% empty %}
<span class="text-muted">All IPs</span>
{% endfor %}
</td>
<td>{{ service.description }}</td>
<td class="text-end noprint">
<a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-sm btn-outline-secondary" title="Change Log">
<i class="mdi mdi-history"></i>
</a>
{% if perms.ipam.change_service %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-warning btn-sm" title="Edit Service">
<i class="mdi mdi-pencil"></i>
</a>
{% endif %}
{% if perms.ipam.delete_service %}
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" title="Delete Service"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
</div>
{% if perms.ipam.add_service %}
{% with object|meta:"model_name" as object_type %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:service_add' %}{% if object_type == "device" %}?device={{ object.pk }}{% elif object_type == "virtualmachine" %}?virtual_machine={{ object.pk }}{% endif %}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Service
</a>
</div>
{% endwith %}
{% endif %}
</div>

View File

@ -39,54 +39,21 @@
<td>Description</td> <td>Description</td>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<td>Sites</td>
<td>
{% if sites_count %}
<a href="{% url 'dcim:site_list' %}?asn_id={{ object.pk }}">{{ sites_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<td>Providers</td>
<td>
{% if providers_count %}
<a href="{% url 'circuits:provider_list' %}?asn_id={{ object.pk }}">{{ providers_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% plugin_left_page object %} {% plugin_left_page object %}
{% include 'inc/panels/tags.html' %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.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' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Sites</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:site_list' %}?asn_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
<div class="card">
<h5 class="card-header">Providers</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:provider_list' %}?asn_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -117,11 +117,16 @@
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %} {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %} {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
{% include 'inc/panels/services.html' %} <div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:service_list' %}?ipaddress_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -32,32 +32,20 @@
<th scope="row">Private</th> <th scope="row">Private</th>
<td>{% checkmark object.is_private %}</td> <td>{% checkmark object.is_private %}</td>
</tr> </tr>
<tr>
<th scope="row">Aggregates</th>
<td>
<a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.pk }}">{{ object.aggregates.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Aggregates</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:aggregate_list' %}?rir_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -32,44 +32,20 @@
<th scope="row">Weight</th> <th scope="row">Weight</th>
<td>{{ object.weight }}</td> <td>{{ object.weight }}</td>
</tr> </tr>
<tr>
<th scope="row">Prefixes</th>
<td>
<a href="{% url 'ipam:prefix_list' %}?role_id={{ object.pk }}">{{ object.prefixes.count }}</a>
</td>
</tr>
<tr>
<th scope="row">IP Ranges</th>
<td>
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ object.ip_ranges.count }}</a>
</td>
</tr>
<tr>
<th scope="row">VLANs</th>
<td>
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ object.vlans.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Prefixes</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:prefix_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -25,18 +25,54 @@
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="mb-4"> {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} {% include 'inc/panels/comments.html' %}
</div>
{% include 'inc/panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Importing VRFs</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:vrf_list' %}?import_target_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Exporting VRFs</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:vrf_list' %}?export_target_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Importing L2VPNs</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:l2vpn_list' %}?import_target_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Exporting L2VPNs</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:l2vpn_list' %}?export_target_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -42,12 +42,6 @@
<th scope="row">Permitted VIDs</th> <th scope="row">Permitted VIDs</th>
<td>{{ object.min_vid }} - {{ object.max_vid }}</td> <td>{{ object.min_vid }} - {{ object.max_vid }}</td>
</tr> </tr>
<tr>
<th scope="row">VLANs</th>
<td>
<a href="{% url 'ipam:vlan_list' %}?group_id={{ object.pk }}">{{ vlans_count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -55,6 +49,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -35,25 +35,14 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Prefixes</th>
<td>
<a href="{% url 'ipam:prefix_list' %}?vrf_id={{ object.pk }}">{{ prefix_count }}</a>
</td>
</tr>
<tr>
<th scope="row">IP Addresses</th>
<td>
<a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.pk }}">{{ ipaddress_count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -67,19 +67,15 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Assignments</th>
<td>{{ assignment_count }}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-5"> <div class="col col-md-5">
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
@ -87,10 +83,10 @@
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Assignments</h5> <h5 class="card-header">Assignments</h5>
<div class="card-body table-responsive"> <div class="card-body htmx-container table-responsive"
{% render_table assignments_table 'inc/table.html' %} hx-get="{% url 'tenancy:contactassignment_list' %}?contact_id={{ object.pk }}"
{% include 'inc/paginator.html' with paginator=assignments_table.paginator page=assignments_table.page %} hx-trigger="load"
</div> ></div>
</div> </div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>

View File

@ -31,12 +31,6 @@
<th scope="row">Parent</th> <th scope="row">Parent</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Contacts</th>
<td>
<a href="{% url 'tenancy:contact_list' %}?group_id={{ object.pk }}">{{ object.contacts.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -44,7 +38,12 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Child Groups</h5> <h5 class="card-header">Child Groups</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"
@ -59,17 +58,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% plugin_right_page object %}
</div>
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Contacts</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'tenancy:contact_list' %}?group_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -22,12 +22,6 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Assignments</th>
<td>
{{ assignment_count }}
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -35,19 +29,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Assigned Contacts</h5>
<div class="card-body table-responsive">
{% render_table contacts_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=contacts_table.paginator page=contacts_table.page %}
</div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -10,12 +10,10 @@
{% endblock breadcrumbs %} {% endblock breadcrumbs %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-7"> <div class="col col-md-7">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Tenant</h5>
Tenant
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
@ -36,101 +34,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-5"> <div class="col col-md-5">
<div class="card"> {% include 'inc/panels/related_objects.html' %}
<h5 class="card-header">
Stats
</h5>
<div class="row card-body">
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:site_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.site_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.site_count }}</a></h2>
<p>Sites</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:rack_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.rack_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
<p>Racks</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:rackreservation_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.rackreservation_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rackreservation_count }}</a></h2>
<p>Rack reservations</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:location_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.location_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.location_count }}</a></h2>
<p>Locations</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
<p>Devices</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:virtualdevicecontext_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vdc_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vdc_count }}</a></h2>
<p>Virtual Device Contexts</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
<p>Cables</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:vrf_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vrf_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vrf_count }}</a></h2>
<p>VRFs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:aggregate_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.aggregate_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.aggregate_count }}</a></h2>
<p>Aggregates</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:asn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
<p>ASNs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
<p>Prefixes</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:iprange_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.iprange_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.iprange_count }}</a></h2>
<p>IP Ranges</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:ipaddress_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.ipaddress_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.ipaddress_count }}</a></h2>
<p>IP addresses</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:vlan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
<p>VLANs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:l2vpn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.l2vpn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.l2vpn_count }}</a></h2>
<p>L2VPNs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'circuits:circuit_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
<p>Circuits</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.virtualmachine_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.virtualmachine_count }}</a></h2>
<p>Virtual machines</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'virtualization:cluster_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cluster_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2>
<p>Clusters</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'wireless:wirelesslan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.wirelesslan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.wirelesslan_count }}</a></h2>
<p>Wireless LANs</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'wireless:wirelesslink_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.wirelesslink_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.wirelesslink_count }}</a></h2>
<p>Wireless Links</p>
</div>
</div>
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -39,12 +39,6 @@
<th scope="row">Parent</th> <th scope="row">Parent</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Tenants</th>
<td>
<a href="{% url 'tenancy:tenant_list' %}?group_id={{ object.pk }}">{{ object.tenants.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -52,7 +46,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Child Groups</h5> <h5 class="card-header">Child Groups</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"
@ -67,18 +67,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Tenants</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'tenancy:tenant_list' %}?group_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -44,10 +44,6 @@
<th scope="row">Site</th> <th scope="row">Site</th>
<td>{{ object.site|linkify|placeholder }}</td> <td>{{ object.site|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Virtual Machines</th>
<td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ object.pk }}">{{ object.virtual_machines.count }}</a></td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -28,12 +28,6 @@
<th scope="row">Description</th> <th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Clusters</th>
<td>
<a href="{% url 'virtualization:cluster_list' %}?group_id={{ object.pk }}">{{ object.clusters.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -41,6 +35,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
@ -48,13 +43,6 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Clusters</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'virtualization:cluster_list' %}?group_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -41,19 +41,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Clusters</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'virtualization:cluster_list' %}?type_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -144,7 +144,20 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/services.html' %} <div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:service_list' %}?virtual_machine_id={{ object.pk }}"
hx-trigger="load"
></div>
{% if perms.ipam.add_service %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:service_add' %}?virtual_machine={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a service
</a>
</div>
{% endif %}
</div>
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -37,12 +37,6 @@
<th scope="row">Parent</th> <th scope="row">Parent</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Wireless LANs</th>
<td>
<a href="{% url 'wireless:wirelesslan_list' %}?group_id={{ object.pk }}">{{ object.wirelesslans.count }}</a>
</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -50,7 +44,13 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card"> <div class="card">
<h5 class="card-header">Child Groups</h5> <h5 class="card-header">Child Groups</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"
@ -65,18 +65,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Wireless LANs</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'wireless:wirelesslan_list' %}?group_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -1,11 +1,17 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.choices import *
from tenancy.models import * from tenancy.models import *
from tenancy.forms import ContactModelFilterForm from tenancy.forms import ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import (
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField,
)
__all__ = ( __all__ = (
'ContactAssignmentFilterForm',
'ContactFilterForm', 'ContactFilterForm',
'ContactGroupFilterForm', 'ContactGroupFilterForm',
'ContactRoleFilterForm', 'ContactRoleFilterForm',
@ -71,3 +77,36 @@ class ContactFilterForm(NetBoxModelFilterSetForm):
label=_('Group') label=_('Group')
) )
tag = TagFilterField(model) 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
)

View File

@ -47,6 +47,7 @@ urlpatterns = [
path('contacts/<int:pk>/', include(get_model_urls('tenancy', 'contact'))), path('contacts/<int:pk>/', include(get_model_urls('tenancy', 'contact'))),
# Contact assignments # 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/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))), path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))),

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext
@ -34,6 +35,16 @@ class TenantGroupListView(generic.ObjectListView):
class TenantGroupView(generic.ObjectView): class TenantGroupView(generic.ObjectView):
queryset = TenantGroup.objects.all() 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') @register_model_view(TenantGroup, 'edit')
class TenantGroupEditView(generic.ObjectEditView): class TenantGroupEditView(generic.ObjectEditView):
@ -92,31 +103,36 @@ class TenantView(generic.ObjectView):
queryset = Tenant.objects.all() queryset = Tenant.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
stats = { related_models = [
'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=instance).count(), # DCIM
'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), # IPAM
'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), # Circuits
'wirelesslan_count': WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
'wirelesslink_count': WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance).count(), # 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 { return {
'stats': stats, 'related_models': related_models,
} }
@ -171,6 +187,16 @@ class ContactGroupListView(generic.ObjectListView):
class ContactGroupView(generic.ObjectView): class ContactGroupView(generic.ObjectView):
queryset = ContactGroup.objects.all() 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') @register_model_view(ContactGroup, 'edit')
class ContactGroupEditView(generic.ObjectEditView): class ContactGroupEditView(generic.ObjectEditView):
@ -229,16 +255,12 @@ class ContactRoleView(generic.ObjectView):
queryset = ContactRole.objects.all() queryset = ContactRole.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( related_models = (
role=instance (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 { return {
'contacts_table': contacts_table, 'related_models': related_models,
'assignment_count': ContactAssignment.objects.filter(role=instance).count(),
} }
@ -288,19 +310,6 @@ class ContactListView(generic.ObjectListView):
class ContactView(generic.ObjectView): class ContactView(generic.ObjectView):
queryset = Contact.objects.all() 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') @register_model_view(Contact, 'edit')
class ContactEditView(generic.ObjectEditView): class ContactEditView(generic.ObjectEditView):
@ -340,6 +349,13 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
# Contact assignments # 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') @register_model_view(ContactAssignment, 'edit')
class ContactAssignmentEditView(generic.ObjectEditView): class ContactAssignmentEditView(generic.ObjectEditView):
queryset = ContactAssignment.objects.all() queryset = ContactAssignment.objects.all()

View File

@ -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): class TokenTable(NetBoxTable):
key = columns.TemplateColumn( key = columns.TemplateColumn(
template_code=TOKEN template_code=TOKEN
@ -32,7 +40,7 @@ class TokenTable(NetBoxTable):
allowed_ips = columns.TemplateColumn( allowed_ips = columns.TemplateColumn(
template_code=ALLOWED_IPS template_code=ALLOWED_IPS
) )
actions = columns.ActionsColumn( actions = TokenActionsColumn(
actions=('edit', 'delete'), actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON extra_buttons=COPY_BUTTON
) )

View File

@ -527,6 +527,7 @@ def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_place
if type(highlight) is re.Pattern: if type(highlight) is re.Pattern:
pre, match, post = highlight.split(value, maxsplit=1) pre, match, post = highlight.split(value, maxsplit=1)
else: else:
highlight = re.escape(highlight)
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE) pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
except ValueError as e: except ValueError as e:
# Match not found # Match not found

View File

@ -10,7 +10,7 @@ from dcim.models import Device
from dcim.tables import DeviceTable from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import IPAddress, Service from ipam.models import IPAddress, Service
from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from ipam.tables import InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
@ -36,17 +36,12 @@ class ClusterTypeView(generic.ObjectView):
queryset = ClusterType.objects.all() queryset = ClusterType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
clusters = Cluster.objects.restrict(request.user, 'view').filter( related_models = (
type=instance (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
).annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
) )
clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',))
clusters_table.configure(request)
return { return {
'clusters_table': clusters_table, 'related_models': related_models,
} }
@ -100,6 +95,15 @@ class ClusterGroupListView(generic.ObjectListView):
class ClusterGroupView(generic.ObjectView): class ClusterGroupView(generic.ObjectView):
queryset = ClusterGroup.objects.all() 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') @register_model_view(ClusterGroup, 'edit')
class ClusterGroupEditView(generic.ObjectEditView): class ClusterGroupEditView(generic.ObjectEditView):
@ -323,32 +327,7 @@ class VirtualMachineListView(generic.ObjectListView):
@register_model_view(VirtualMachine) @register_model_view(VirtualMachine)
class VirtualMachineView(generic.ObjectView): class VirtualMachineView(generic.ObjectView):
queryset = VirtualMachine.objects.prefetch_related('tenant__group') queryset = VirtualMachine.objects.all()
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,
}
@register_model_view(VirtualMachine, 'interfaces') @register_model_view(VirtualMachine, 'interfaces')

View File

@ -27,6 +27,16 @@ class WirelessLANGroupListView(generic.ObjectListView):
class WirelessLANGroupView(generic.ObjectView): class WirelessLANGroupView(generic.ObjectView):
queryset = WirelessLANGroup.objects.all() 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') @register_model_view(WirelessLANGroup, 'edit')
class WirelessLANGroupEditView(generic.ObjectEditView): class WirelessLANGroupEditView(generic.ObjectEditView):