Merge pull request #5512 from netbox-community/develop

Release v2.10.2
This commit is contained in:
Jeremy Stretch 2020-12-21 16:17:49 -05:00 committed by GitHub
commit 77e2b0e4ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 378 additions and 182 deletions

View File

@ -1,5 +1,5 @@
name: CI name: CI
on: push on: [push, pull_request]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -7,10 +7,10 @@ to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations. function as a domain-specific source of truth for network operations.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions), Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)! or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
@ -36,7 +36,7 @@ or join us in the **#netbox** Slack channel on [NetworkToCode](https://networkto
## Installation ## Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and [latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`. run `upgrade.sh`.

View File

@ -4,10 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2. Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
!!! warning
Support for Django's native templating logic will be removed in NetBox v2.10.
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:

View File

@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
Default: None Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set: The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
```python ```python
BASE_PATH = 'netbox/' BASE_PATH = 'netbox/'
@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
## NAPALM_ARGS ## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python ```python
NAPALM_ARGS = { NAPALM_ARGS = {

View File

@ -1,6 +1,6 @@
# HTTP Server Setup # HTTP Server Setup
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible. This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info !!! info
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed. For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.

View File

@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
``` ```
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
### General Server Configuration ### General Server Configuration

View File

@ -63,11 +63,15 @@ setup(
install_requires=[], install_requires=[],
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
zip_safe=False,
) )
``` ```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig ### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:

View File

@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) #### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
### Enhancements ### Enhancements

View File

@ -1,5 +1,29 @@
# NetBox v2.10 # NetBox v2.10
## v2.10.2 (2020-12-21)
### Enhancements
* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list
* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user
### Bug Fixes
* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
---
## v2.10.1 (2020-12-15) ## v2.10.1 (2020-12-15)
### Bug Fixes ### Bug Fixes

View File

@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) #### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
### Enhancements ### Enhancements

View File

@ -2,7 +2,7 @@
## What is a REST API? ## What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
* `GET`: Retrieve an object or list of objects * `GET`: Retrieve an object or list of objects
* `POST`: Create an object * `POST`: Create an object

View File

@ -1,5 +1,4 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
@ -7,7 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from dcim.api.views import PathEndpointMixin from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import count_related
from . import serializers from . import serializers
@ -25,7 +24,7 @@ class CircuitsRootView(APIRootView):
class ProviderViewSet(CustomFieldModelViewSet): class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate( queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Coalesce(get_subquery(Circuit, 'provider'), 0) circuit_count=count_related(Circuit, 'provider')
) )
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet filterset_class = filters.ProviderFilterSet
@ -37,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet): class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0) circuit_count=count_related(Circuit, 'type')
) )
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet filterset_class = filters.CircuitTypeFilterSet

View File

@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderListView(generic.ObjectListView): class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
) )
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm
@ -67,7 +67,7 @@ class ProviderBulkImportView(generic.BulkImportView):
class ProviderBulkEditView(generic.BulkEditView): class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
) )
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -76,7 +76,7 @@ class ProviderBulkEditView(generic.BulkEditView):
class ProviderBulkDeleteView(generic.BulkDeleteView): class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
) )
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -88,7 +88,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
class CircuitTypeListView(generic.ObjectListView): class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
@ -110,7 +110,7 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type') circuit_count=count_related(Circuit, 'type')
) )
table = tables.CircuitTypeTable table = tables.CircuitTypeTable

View File

@ -3,7 +3,6 @@ from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.db.models.functions import Coalesce
from django.http import HttpResponseForbidden, HttpResponse from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
@ -31,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
@ -120,12 +119,12 @@ class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related( queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags' 'region', 'tenant', 'tags'
).annotate( ).annotate(
device_count=Coalesce(get_subquery(Device, 'site'), 0), device_count=count_related(Device, 'site'),
rack_count=Coalesce(get_subquery(Rack, 'site'), 0), rack_count=count_related(Rack, 'site'),
prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0), prefix_count=count_related(Prefix, 'site'),
vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0), vlan_count=count_related(VLAN, 'site'),
circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0), circuit_count=count_related(Circuit, 'terminations__site'),
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0), virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
) )
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet filterset_class = filters.SiteFilterSet
@ -153,7 +152,7 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=Coalesce(get_subquery(Rack, 'role'), 0) rack_count=count_related(Rack, 'role')
) )
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet filterset_class = filters.RackRoleFilterSet
@ -167,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.prefetch_related( queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags' 'site', 'group__site', 'role', 'tenant', 'tags'
).annotate( ).annotate(
device_count=Coalesce(get_subquery(Device, 'rack'), 0), device_count=count_related(Device, 'rack'),
powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0) powerfeed_count=count_related(PowerFeed, 'rack')
) )
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet filterset_class = filters.RackFilterSet
@ -241,9 +240,9 @@ class RackReservationViewSet(ModelViewSet):
class ManufacturerViewSet(ModelViewSet): class ManufacturerViewSet(ModelViewSet):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0), devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0) platform_count=count_related(Platform, 'manufacturer')
) )
serializer_class = serializers.ManufacturerSerializer serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet filterset_class = filters.ManufacturerFilterSet
@ -255,7 +254,7 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Coalesce(get_subquery(Device, 'device_type'), 0) device_count=count_related(Device, 'device_type')
) )
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet filterset_class = filters.DeviceTypeFilterSet
@ -319,8 +318,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
class DeviceRoleViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
device_count=Coalesce(get_subquery(Device, 'device_role'), 0), device_count=count_related(Device, 'device_role'),
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0) virtualmachine_count=count_related(VirtualMachine, 'role')
) )
serializer_class = serializers.DeviceRoleSerializer serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet filterset_class = filters.DeviceRoleFilterSet
@ -332,8 +331,8 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate( queryset = Platform.objects.annotate(
device_count=Coalesce(get_subquery(Device, 'platform'), 0), device_count=count_related(Device, 'platform'),
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0) virtualmachine_count=count_related(VirtualMachine, 'platform')
) )
serializer_class = serializers.PlatformSerializer serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet filterset_class = filters.PlatformFilterSet
@ -343,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
# Devices # Devices
# #
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin): class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@ -597,7 +596,7 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate( queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=Coalesce(get_subquery(Device, 'virtual_chassis'), 0) member_count=count_related(Device, 'virtual_chassis')
) )
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet filterset_class = filters.VirtualChassisFilterSet
@ -611,7 +610,7 @@ class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0) powerfeed_count=count_related(PowerFeed, 'power_panel')
) )
serializer_class = serializers.PowerPanelSerializer serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet filterset_class = filters.PowerPanelFilterSet

View File

@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
choices=RackStatusChoices, choices=RackStatusChoices,
null_value=None null_value=None
) )
type = django_filters.MultipleChoiceFilter(
choices=RackTypeChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
)
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
label='Role (ID)', label='Role (ID)',
@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_width', 'outer_depth', 'outer_unit', 'outer_unit',
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
label='User (ID)', label='User (ID)',
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user', field_name='user__username',
queryset=User.objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label='User (name)', label='User (name)',

View File

@ -21,7 +21,7 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm, ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
type = forms.MultipleChoiceField(
choices=RackTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False,
widget=StaticSelect2Multiple()
)
role = DynamicModelMultipleChoiceField( role = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation model = RackReservation
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant'] field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
required=False, required=False,
label='Search' label='Search'
@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
label='Rack group', label='Rack group',
null_option='None' null_option='None'
) )
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,5 +1,6 @@
import logging import logging
from cacheops import invalidate_obj
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction from django.db import transaction
@ -30,6 +31,7 @@ def rebuild_paths(obj):
with transaction.atomic(): with transaction.atomic():
for cp in cable_paths: for cp in cable_paths:
invalidate_obj(cp.origin)
cp.delete() cp.delete()
create_cablepath(cp.origin) create_cablepath(cp.origin)

View File

@ -447,7 +447,8 @@ class DeviceInterfaceTable(InterfaceTable):
'connection', 'actions', 'connection', 'actions',
) )
row_attrs = { row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else '' 'class': lambda record: record.cable.get_status_class() if record.cable else '',
'data-name': lambda record: record.name,
} }

View File

@ -329,7 +329,7 @@ class RackTestCase(TestCase):
racks = ( racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
) )
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
@ -351,13 +351,11 @@ class RackTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self): def test_type(self):
# TODO: Test for multiple values params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
params = {'type': RackTypeChoices.TYPE_2POST} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_width(self): def test_width(self):
# TODO: Test for multiple values params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
params = {'width': RackWidthChoices.WIDTH_19IN}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_u_height(self): def test_u_height(self):
@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
users = User.objects.all()[:2] users = User.objects.all()[:2]
params = {'user_id': [users[0].pk, users[1].pk]} params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Filtering by username is broken params = {'user': [users[0].username, users[1].username]}
# params = {'user': [users[0].username, users[1].username]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self): def test_tenant(self):
tenants = Tenant.objects.all()[:2] tenants = Tenant.objects.all()[:2]

View File

@ -20,7 +20,7 @@ from secrets.models import Secret
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count 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 csv_format, get_subquery from utilities.utils import csv_format, count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
@ -254,7 +254,7 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
class RackRoleListView(generic.ObjectListView): class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
table = tables.RackRoleTable table = tables.RackRoleTable
@ -276,7 +276,7 @@ class RackRoleBulkImportView(generic.BulkImportView):
class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role') rack_count=count_related(Rack, 'role')
) )
table = tables.RackRoleTable table = tables.RackRoleTable
@ -289,7 +289,7 @@ class RackListView(generic.ObjectListView):
queryset = Rack.objects.prefetch_related( queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type' 'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate( ).annotate(
device_count=get_subquery(Device, 'rack') device_count=count_related(Device, 'rack')
) )
filterset = filters.RackFilterSet filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm filterset_form = forms.RackFilterForm
@ -470,9 +470,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView): class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'), devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -494,7 +494,7 @@ class ManufacturerBulkImportView(generic.BulkImportView):
class ManufacturerBulkDeleteView(generic.BulkDeleteView): class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer') devicetype_count=count_related(DeviceType, 'manufacturer')
) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -505,7 +505,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
class DeviceTypeListView(generic.ObjectListView): class DeviceTypeListView(generic.ObjectListView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type') instance_count=count_related(Device, 'device_type')
) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm filterset_form = forms.DeviceTypeFilterForm
@ -612,7 +612,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkEditView(generic.BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type') instance_count=count_related(Device, 'device_type')
) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@ -621,7 +621,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type') instance_count=count_related(Device, 'device_type')
) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@ -913,8 +913,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceRoleListView(generic.ObjectListView): class DeviceRoleListView(generic.ObjectListView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
vm_count=get_subquery(VirtualMachine, 'role') vm_count=count_related(VirtualMachine, 'role')
) )
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
@ -945,8 +945,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
class PlatformListView(generic.ObjectListView): class PlatformListView(generic.ObjectListView):
queryset = Platform.objects.annotate( queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'), device_count=count_related(Device, 'platform'),
vm_count=get_subquery(VirtualMachine, 'platform') vm_count=count_related(VirtualMachine, 'platform')
) )
table = tables.PlatformTable table = tables.PlatformTable
@ -2335,7 +2335,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
class VirtualChassisListView(generic.ObjectListView): class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.prefetch_related('master').annotate( queryset = VirtualChassis.objects.prefetch_related('master').annotate(
member_count=get_subquery(Device, 'virtual_chassis') member_count=count_related(Device, 'virtual_chassis')
) )
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet filterset = filters.VirtualChassisFilterSet
@ -2565,7 +2565,7 @@ class PowerPanelListView(generic.ObjectListView):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel') powerfeed_count=count_related(PowerFeed, 'power_panel')
) )
filterset = filters.PowerPanelFilterSet filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm filterset_form = forms.PowerPanelFilterForm
@ -2615,7 +2615,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
powerfeed_count=get_subquery(PowerFeed, 'power_panel') powerfeed_count=count_related(PowerFeed, 'power_panel')
) )
filterset = filters.PowerPanelFilterSet filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable table = tables.PowerPanelTable

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models.functions import Coalesce
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -22,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, get_subquery from utilities.utils import copy_safe_request, count_related
from . import serializers from . import serializers
@ -103,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet): class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0) tagged_items=count_related(TaggedItem, 'tag')
) )
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet filterset_class = filters.TagFilterSet

View File

@ -2,6 +2,7 @@ import django_filters
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.forms import DateField, IntegerField, NullBooleanField
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
""" """
def __init__(self, custom_field, *args, **kwargs): def __init__(self, custom_field, *args, **kwargs):
self.custom_field = custom_field self.custom_field = custom_field
if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
self.field_class = IntegerField
elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
self.field_class = NullBooleanField
elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
self.field_class = DateField
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def filter(self, queryset, value): self.field_name = f'custom_field_data__{self.field_name}'
# Skip filter on empty value if custom_field.type not in EXACT_FILTER_TYPES:
if value is None or not value.strip(): if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
return queryset self.lookup_expr = 'icontains'
# Apply the assigned filter logic (exact or loose)
if (
self.custom_field.type in EXACT_FILTER_TYPES or
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
):
kwargs = {f'custom_field_data__{self.field_name}': value}
else:
kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
return queryset.filter(**kwargs)
class CustomFieldModelFilterSet(django_filters.FilterSet): class CustomFieldModelFilterSet(django_filters.FilterSet):

View File

@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
cf.save() cf.save()
cf.content_types.set([ct]) cf.content_types.set([ct])
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
required=False,
choices=['Bar', 'Foo']
)
cf_select.save()
cf_select.content_types.set([ct])
def test_create_object(self): def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2') tags = self.create_tags('Tag 1', 'Tag 2')
form_data = { form_data = {
@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'test-site-1', 'slug': 'test-site-1',
'status': SiteStatusChoices.STATUS_ACTIVE, 'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC', 'cf_my_field': 'ABC',
'cf_my_field_select': 'Bar',
'tags': [tag.pk for tag in tags], 'tags': [tag.pk for tag in tags],
} }
@ -54,6 +65,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc_list[0].changed_object, site) self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field']) self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'test-site-x', 'slug': 'test-site-x',
'status': SiteStatusChoices.STATUS_PLANNED, 'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF', 'cf_my_field': 'DEF',
'cf_my_field_select': 'Foo',
'tags': [tags[2].pk], 'tags': [tags[2].pk],
} }
@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field']) self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.object_data['tags'], ['Tag 3']) self.assertEqual(oc.object_data['tags'], ['Tag 3'])
def test_delete_object(self): def test_delete_object(self):
@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Test Site 1', name='Test Site 1',
slug='test-site-1', slug='test-site-1',
custom_field_data={ custom_field_data={
'my_field': 'ABC' 'my_field': 'ABC',
'my_field_select': 'Bar'
} }
) )
site.save() site.save()
@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC') self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
cf.save() cf.save()
cf.content_types.set([ct]) cf.content_types.set([ct])
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
required=False,
choices=['Bar', 'Foo']
)
cf_select.save()
cf_select.content_types.set([ct])
# Create some tags # Create some tags
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Test Site 1', 'name': 'Test Site 1',
'slug': 'test-site-1', 'slug': 'test-site-1',
'custom_fields': { 'custom_fields': {
'my_field': 'ABC' 'my_field': 'ABC',
'my_field_select': 'Bar',
}, },
'tags': [ 'tags': [
{'name': 'Tag 1'}, {'name': 'Tag 1'},
@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Test Site X', 'name': 'Test Site X',
'slug': 'test-site-x', 'slug': 'test-site-x',
'custom_fields': { 'custom_fields': {
'my_field': 'DEF' 'my_field': 'DEF',
'my_field_select': 'Foo',
}, },
'tags': [ 'tags': [
{'name': 'Tag 3'} {'name': 'Tag 3'}
@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
name='Test Site 1', name='Test Site 1',
slug='test-site-1', slug='test-site-1',
custom_field_data={ custom_field_data={
'my_field': 'ABC' 'my_field': 'ABC',
'my_field_select': 'Bar'
} }
) )
site.save() site.save()
@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None) self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'}) self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])

View File

@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from dcim.filters import SiteFilterSet
from dcim.forms import SiteCSVForm from dcim.forms import SiteCSVForm
from dcim.models import Site, Rack from dcim.models import Site, Rack
from extras.choices import * from extras.choices import *
@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
site.cf['baz'] = 'def' site.cf['baz'] = 'def'
site.clean() site.clean()
class CustomFieldFilterTest(TestCase):
queryset = Site.objects.all()
filterset = SiteFilterSet
@classmethod
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
# Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save()
cf.content_types.set([obj_type])
# Boolean filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf.save()
cf.content_types.set([obj_type])
# Exact text filtering
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf.save()
cf.content_types.set([obj_type])
# Loose text filtering
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf.save()
cf.content_types.set([obj_type])
# Date filtering
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
cf.save()
cf.content_types.set([obj_type])
# Exact URL filtering
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf.save()
cf.content_types.set([obj_type])
# Loose URL filtering
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf.save()
cf.content_types.set([obj_type])
# Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
cf.save()
cf.content_types.set([obj_type])
Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', custom_field_data={
'cf1': 100,
'cf2': True,
'cf3': 'foo',
'cf4': 'foo',
'cf5': '2016-06-26',
'cf6': 'http://foo.example.com/',
'cf7': 'http://foo.example.com/',
'cf8': 'Foo',
}),
Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200,
'cf2': False,
'cf3': 'foobar',
'cf4': 'foobar',
'cf5': '2016-06-27',
'cf6': 'http://bar.example.com/',
'cf7': 'http://bar.example.com/',
'cf8': 'Bar',
}),
Site(name='Site 3', slug='site-3', custom_field_data={
}),
])
def test_filter_integer(self):
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
def test_filter_text(self):
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
def test_filter_date(self):
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
def test_filter_url(self):
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)

View File

@ -12,7 +12,7 @@ from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables from . import filters, forms, tables
from .choices import JobResultStatusChoices from .choices import JobResultStatusChoices
@ -27,7 +27,7 @@ from .scripts import get_scripts, run_script
class TagListView(generic.ObjectListView): class TagListView(generic.ObjectListView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag') items=count_related(TaggedItem, 'tag')
) )
filterset = filters.TagFilterSet filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm filterset_form = forms.TagFilterForm
@ -52,7 +52,7 @@ class TagBulkImportView(generic.BulkImportView):
class TagBulkEditView(generic.BulkEditView): class TagBulkEditView(generic.BulkEditView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag') items=count_related(TaggedItem, 'tag')
) )
table = tables.TagTable table = tables.TagTable
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
@ -60,7 +60,7 @@ class TagBulkEditView(generic.BulkEditView):
class TagBulkDeleteView(generic.BulkDeleteView): class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=get_subquery(TaggedItem, 'tag') items=count_related(TaggedItem, 'tag')
) )
table = tables.TagTable table = tables.TagTable

View File

@ -1,5 +1,4 @@
from django.conf import settings from django.conf import settings
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
@ -13,7 +12,7 @@ from ipam import filters
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from utilities.constants import ADVISORY_LOCK_KEYS from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import get_subquery from utilities.utils import count_related
from . import serializers from . import serializers
@ -33,8 +32,8 @@ class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related( queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags' 'import_targets', 'export_targets', 'tags'
).annotate( ).annotate(
ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0), ipaddress_count=count_related(IPAddress, 'vrf'),
prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0) prefix_count=count_related(Prefix, 'vrf')
) )
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
filterset_class = filters.VRFFilterSet filterset_class = filters.VRFFilterSet
@ -56,7 +55,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
class RIRViewSet(ModelViewSet): class RIRViewSet(ModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0) aggregate_count=count_related(Aggregate, 'rir')
) )
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
filterset_class = filters.RIRFilterSet filterset_class = filters.RIRFilterSet
@ -78,8 +77,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
class RoleViewSet(ModelViewSet): class RoleViewSet(ModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0), prefix_count=count_related(Prefix, 'role'),
vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0) vlan_count=count_related(VLAN, 'role')
) )
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
filterset_class = filters.RoleFilterSet filterset_class = filters.RoleFilterSet
@ -273,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet): class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Coalesce(get_subquery(VLAN, 'group'), 0) vlan_count=count_related(VLAN, 'group')
) )
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
filterset_class = filters.VLANGroupFilterSet filterset_class = filters.VLANGroupFilterSet
@ -287,7 +286,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'site', 'group', 'tenant', 'role', 'tags'
).annotate( ).annotate(
prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0) prefix_count=count_related(Prefix, 'vlan')
) )
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet filterset_class = filters.VLANFilterSet

View File

@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
def clean(self): def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface # Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):

View File

@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
""" """
PREFIX_LINK = """ PREFIX_LINK = """
{% if record.children %} {% load helpers %}
<span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a> {% for i in record.parents|as_range %}
{% else %} <i class="mdi mdi-circle-small"></i>
<span class="text-nowrap" style="padding-left: {{ record.parents }}9px"> {% endfor %}
{% endif %} <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
</span>
""" """
PREFIX_ROLE_LINK = """ PREFIX_ROLE_LINK = """
@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
""" """
VLAN_MEMBER_TAGGED = """ VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == vlan.pk %} {% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span> <span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %} {% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> <span class="text-success"><i class="mdi mdi-check-bold"></i></span>

View File

@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
from dcim.models import Device, Interface from dcim.models import Device, Interface
from netbox.views import generic from netbox.views import generic
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables from . import filters, forms, tables
from .constants import * from .constants import *
@ -140,7 +140,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
class RIRListView(generic.ObjectListView): class RIRListView(generic.ObjectListView):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
) )
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm filterset_form = forms.RIRFilterForm
@ -165,7 +165,7 @@ class RIRBulkImportView(generic.BulkImportView):
class RIRBulkDeleteView(generic.BulkDeleteView): class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
) )
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
table = tables.RIRTable table = tables.RIRTable
@ -277,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
class RoleListView(generic.ObjectListView): class RoleListView(generic.ObjectListView):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role') vlan_count=count_related(VLAN, 'role')
) )
table = tables.RoleTable table = tables.RoleTable
@ -633,7 +633,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
class VLANGroupListView(generic.ObjectListView): class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.prefetch_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
) )
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
@ -657,7 +657,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
class VLANGroupBulkDeleteView(generic.BulkDeleteView): class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=get_subquery(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
) )
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable

View File

@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed. # Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = '' BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
# BASE_PATH = 'netbox/' # BASE_PATH = 'netbox/'
BASE_PATH = '' BASE_PATH = ''
@ -183,7 +183,7 @@ NAPALM_PASSWORD = ''
# NAPALM timeout (in seconds). (Default: 30) # NAPALM timeout (in seconds). (Default: 30)
NAPALM_TIMEOUT = 30 NAPALM_TIMEOUT = 30
# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must # NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
# be provided as a dictionary. # be provided as a dictionary.
NAPALM_ARGS = {} NAPALM_ARGS = {}

View File

@ -23,7 +23,7 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilterSet from tenancy.filters import TenantFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from tenancy.tables import TenantTable from tenancy.tables import TenantTable
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable from virtualization.tables import ClusterTable, VirtualMachineDetailTable
@ -33,7 +33,7 @@ SEARCH_TYPES = OrderedDict((
# Circuits # Circuits
('provider', { ('provider', {
'queryset': Provider.objects.annotate( 'queryset': Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=count_related(Circuit, 'provider')
), ),
'filterset': ProviderFilterSet, 'filterset': ProviderFilterSet,
'table': ProviderTable, 'table': ProviderTable,
@ -74,7 +74,7 @@ SEARCH_TYPES = OrderedDict((
}), }),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=get_subquery(Device, 'device_type') instance_count=count_related(Device, 'device_type')
), ),
'filterset': DeviceTypeFilterSet, 'filterset': DeviceTypeFilterSet,
'table': DeviceTypeTable, 'table': DeviceTypeTable,
@ -90,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
}), }),
('virtualchassis', { ('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate( 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=get_subquery(Device, 'virtual_chassis') member_count=count_related(Device, 'virtual_chassis')
), ),
'filterset': VirtualChassisFilterSet, 'filterset': VirtualChassisFilterSet,
'table': VirtualChassisTable, 'table': VirtualChassisTable,
@ -111,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
# Virtualization # Virtualization
('cluster', { ('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=get_subquery(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
), ),
'filterset': ClusterFilterSet, 'filterset': ClusterFilterSet,
'table': ClusterTable, 'table': ClusterTable,

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.10.1' VERSION = '2.10.2'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -1,11 +1,10 @@
// Inteface filtering // Inteface filtering
$('input.interface-filter').on('input', function() { $('input.interface-filter').on('input', function() {
var filter = new RegExp(this.value); let filter = new RegExp(this.value);
var interface; let interface;
for (interface of $('table > tbody > tr')) { for (interface of $('table > tbody > tr')) {
// Slice off 'interface_' at the start of the ID if (filter.test(interface.getAttribute('data-name'))) {
if (filter.test(interface.id.slice(10))) {
// Match the toggle in case the filter now matches the interface // Match the toggle in case the filter now matches the interface
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked')); $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
$(interface).show(); $(interface).show();

View File

@ -1,7 +1,6 @@
import base64 import base64
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -13,7 +12,7 @@ from netbox.api.views import ModelViewSet
from secrets import filters from secrets import filters
from secrets.exceptions import InvalidKey from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.utils import get_subquery from utilities.utils import count_related
from . import serializers from . import serializers
ERR_USERKEY_MISSING = "No UserKey found for the current user." ERR_USERKEY_MISSING = "No UserKey found for the current user."
@ -36,7 +35,7 @@ class SecretsRootView(APIRootView):
class SecretRoleViewSet(ModelViewSet): class SecretRoleViewSet(ModelViewSet):
queryset = SecretRole.objects.annotate( queryset = SecretRole.objects.annotate(
secret_count=Coalesce(get_subquery(Secret, 'role'), 0) secret_count=count_related(Secret, 'role')
) )
serializer_class = serializers.SecretRoleSerializer serializer_class = serializers.SecretRoleSerializer
filterset_class = filters.SecretRoleFilterSet filterset_class = filters.SecretRoleFilterSet

View File

@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
self.fields['plaintext'].required = True self.fields['plaintext'].required = True
def clean(self): def clean(self):
super().clean()
if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']: if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.") raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")

View File

@ -7,7 +7,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from netbox.views import generic from netbox.views import generic
from utilities.utils import get_subquery from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .models import SecretRole, Secret, SessionKey, UserKey from .models import SecretRole, Secret, SessionKey, UserKey
@ -28,7 +28,7 @@ def get_session_key(request):
class SecretRoleListView(generic.ObjectListView): class SecretRoleListView(generic.ObjectListView):
queryset = SecretRole.objects.annotate( queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role') secret_count=count_related(Secret, 'role')
) )
table = tables.SecretRoleTable table = tables.SecretRoleTable
@ -50,7 +50,7 @@ class SecretRoleBulkImportView(generic.BulkImportView):
class SecretRoleBulkDeleteView(generic.BulkDeleteView): class SecretRoleBulkDeleteView(generic.BulkDeleteView):
queryset = SecretRole.objects.annotate( queryset = SecretRole.objects.annotate(
secret_count=get_subquery(Secret, 'role') secret_count=count_related(Secret, 'role')
) )
table = tables.SecretRoleTable table = tables.SecretRoleTable

View File

@ -71,7 +71,7 @@
</div> </div>
<div class="col-xs-4 text-right noprint"> <div class="col-xs-4 text-right noprint">
<p class="text-muted"> <p class="text-muted">
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> &middot; <i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> &middot;
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot; <i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot; <i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot;
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a> <i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>

View File

@ -330,12 +330,16 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="row" style="margin-bottom: 20px"> <div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12 text-center"> <div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Front</h4> <h4>Front</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' %} {% include 'dcim/inc/rack_elevation.html' with face='front' %}
</div>
</div> </div>
<div class="col-md-6 col-sm-6 col-xs-12 text-center"> <div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Rear</h4> <h4>Rear</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' %} {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
</div>
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -25,7 +25,8 @@
{% if page %} {% if page %}
<div style="white-space: nowrap; overflow-x: scroll;"> <div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %} {% for rack in page %}
<div style="display: inline-block; width: 266px"> <div style="display: inline-block; margin-right: 12px; width: 254px">
<div style="margin-left: 30px">
<div class="text-center"> <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.role %} {% if rack.role %}
@ -43,6 +44,7 @@
<small class="text-muted">({{ rack.facility_id }})</small> <small class="text-muted">({{ rack.facility_id }})</small>
{% endif %} {% endif %}
</div> </div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -137,7 +137,7 @@
<td> <td>
{% if object.physical_address %} {% if object.physical_address %}
<div class="pull-right noprint"> <div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs"> <a href="https://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
<i class="mdi mdi-map-marker"></i> Map it <i class="mdi mdi-map-marker"></i> Map it
</a> </a>
</div> </div>
@ -156,7 +156,7 @@
<td> <td>
{% if object.latitude and object.longitude %} {% if object.latitude and object.longitude %}
<div class="pull-right noprint"> <div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs"> <a href="https://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
<i class="mdi mdi-map-marker"></i> Map it <i class="mdi mdi-map-marker"></i> Map it
</a> </a>
</div> </div>

View File

@ -78,7 +78,7 @@
</tr> </tr>
<tr> <tr>
<td>Description</td> <td>Description</td>
<td>{{ vrf.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@ -1,4 +1,7 @@
{% if secrets %} {% if secrets %}
<form id="secret_form">
{% csrf_token %}
</form>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for secret in secrets %} {% for secret in secrets %}
<tr> <tr>

View File

@ -317,5 +317,6 @@
{% block javascript %} {% block javascript %}
<script src="{% static 'js/interface_filtering.js' %}?v{{ settings.VERSION }}"></script> <script src="{% static 'js/interface_filtering.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script> <script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,3 @@
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits.models import Circuit from circuits.models import Circuit
@ -8,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from tenancy import filters from tenancy import filters
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
@ -45,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.prefetch_related( queryset = Tenant.objects.prefetch_related(
'group', 'tags' 'group', 'tags'
).annotate( ).annotate(
circuit_count=get_subquery(Circuit, 'tenant'), circuit_count=count_related(Circuit, 'tenant'),
device_count=get_subquery(Device, 'tenant'), device_count=count_related(Device, 'tenant'),
ipaddress_count=Coalesce(get_subquery(IPAddress, 'tenant'), 0), ipaddress_count=count_related(IPAddress, 'tenant'),
prefix_count=Coalesce(get_subquery(Prefix, 'tenant'), 0), prefix_count=count_related(Prefix, 'tenant'),
rack_count=Coalesce(get_subquery(Rack, 'tenant'), 0), rack_count=count_related(Rack, 'tenant'),
site_count=Coalesce(get_subquery(Site, 'tenant'), 0), site_count=count_related(Site, 'tenant'),
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'tenant'), 0), virtualmachine_count=count_related(VirtualMachine, 'tenant'),
vlan_count=Coalesce(get_subquery(VLAN, 'tenant'), 0), vlan_count=count_related(VLAN, 'tenant'),
vrf_count=Coalesce(get_subquery(VRF, 'tenant'), 0) vrf_count=count_related(VRF, 'tenant')
) )
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
filterset_class = filters.TenantFilterSet filterset_class = filters.TenantFilterSet

View File

@ -208,6 +208,18 @@ def split(string, sep=','):
return string.split(sep) return string.split(sep)
@register.filter()
def as_range(n):
"""
Return a range of n items.
"""
try:
int(n)
except TypeError:
return list()
return range(n)
# #
# Tags # Tags
# #

View File

@ -5,6 +5,7 @@ from itertools import count, groupby
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from jinja2 import Environment from jinja2 import Environment
from dcim.choices import CableLengthUnitChoices from dcim.choices import CableLengthUnitChoices
@ -65,7 +66,7 @@ def dynamic_import(name):
return mod return mod
def get_subquery(model, field): def count_related(model, field):
""" """
Return a Subquery suitable for annotating a child object count. Return a Subquery suitable for annotating a child object count.
""" """
@ -79,7 +80,7 @@ def get_subquery(model, field):
).values('c') ).values('c')
) )
return subquery return Coalesce(subquery, 0)
def serialize_object(obj, extra=None, exclude=None): def serialize_object(obj, extra=None, exclude=None):

View File

@ -1,9 +1,8 @@
from django.db.models.functions import Coalesce
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization import filters from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from . import serializers from . import serializers
@ -23,7 +22,7 @@ class VirtualizationRootView(APIRootView):
class ClusterTypeViewSet(ModelViewSet): class ClusterTypeViewSet(ModelViewSet):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=Coalesce(get_subquery(Cluster, 'type'), 0) cluster_count=count_related(Cluster, 'type')
) )
serializer_class = serializers.ClusterTypeSerializer serializer_class = serializers.ClusterTypeSerializer
filterset_class = filters.ClusterTypeFilterSet filterset_class = filters.ClusterTypeFilterSet
@ -31,7 +30,7 @@ class ClusterTypeViewSet(ModelViewSet):
class ClusterGroupViewSet(ModelViewSet): class ClusterGroupViewSet(ModelViewSet):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=Coalesce(get_subquery(Cluster, 'group'), 0) cluster_count=count_related(Cluster, 'group')
) )
serializer_class = serializers.ClusterGroupSerializer serializer_class = serializers.ClusterGroupSerializer
filterset_class = filters.ClusterGroupFilterSet filterset_class = filters.ClusterGroupFilterSet
@ -41,8 +40,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.prefetch_related( queryset = Cluster.objects.prefetch_related(
'type', 'group', 'tenant', 'site', 'tags' 'type', 'group', 'tenant', 'site', 'tags'
).annotate( ).annotate(
device_count=Coalesce(get_subquery(Device, 'cluster'), 0), device_count=count_related(Device, 'cluster'),
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster'), 0) virtualmachine_count=count_related(VirtualMachine, 'cluster')
) )
serializer_class = serializers.ClusterSerializer serializer_class = serializers.ClusterSerializer
filterset_class = filters.ClusterFilterSet filterset_class = filters.ClusterFilterSet
@ -52,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
# Virtual machines # Virtual machines
# #
class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin): class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
queryset = VirtualMachine.objects.prefetch_related( queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
) )

View File

@ -183,3 +183,6 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
default_columns = ( default_columns = (
'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
) )
row_attrs = {
'data-name': lambda record: record.name,
}

View File

@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from secrets.models import Secret from secrets.models import Secret
from utilities.utils import get_subquery from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -22,7 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
class ClusterTypeListView(generic.ObjectListView): class ClusterTypeListView(generic.ObjectListView):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type') cluster_count=count_related(Cluster, 'type')
) )
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -44,7 +44,7 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
class ClusterTypeBulkDeleteView(generic.BulkDeleteView): class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate( queryset = ClusterType.objects.annotate(
cluster_count=get_subquery(Cluster, 'type') cluster_count=count_related(Cluster, 'type')
) )
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -55,7 +55,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
class ClusterGroupListView(generic.ObjectListView): class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group') cluster_count=count_related(Cluster, 'group')
) )
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@ -77,7 +77,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
class ClusterGroupBulkDeleteView(generic.BulkDeleteView): class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate( queryset = ClusterGroup.objects.annotate(
cluster_count=get_subquery(Cluster, 'group') cluster_count=count_related(Cluster, 'group')
) )
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@ -89,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
class ClusterListView(generic.ObjectListView): class ClusterListView(generic.ObjectListView):
permission_required = 'virtualization.view_cluster' permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.annotate( queryset = Cluster.objects.annotate(
device_count=get_subquery(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
) )
table = tables.ClusterTable table = tables.ClusterTable
filterset = filters.ClusterFilterSet filterset = filters.ClusterFilterSet