mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
commit
77e2b0e4ba
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: CI
|
||||
on: push
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -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.
|
||||
|
||||
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).
|
||||
|
||||
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),
|
||||
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
|
||||
|
||||
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
|
||||
[latest release](https://github.com/netbox-community/netbox/releases) and
|
||||
run `upgrade.sh`.
|
||||
|
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
!!! warning
|
||||
Support for Django's native templating logic will be removed in NetBox v2.10.
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
|
||||
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:
|
||||
|
||||
|
@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
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
|
||||
BASE_PATH = 'netbox/'
|
||||
@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
|
||||
|
||||
## 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
|
||||
NAPALM_ARGS = {
|
||||
|
@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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.
|
||||
|
@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
|
||||
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
|
||||
|
||||
|
@ -63,11 +63,15 @@ setup(
|
||||
install_requires=[],
|
||||
packages=find_packages(),
|
||||
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).
|
||||
|
||||
!!! 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
|
||||
|
||||
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:
|
||||
|
@ -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))
|
||||
|
||||
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
|
||||
|
||||
|
@ -1,5 +1,29 @@
|
||||
# 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)
|
||||
|
||||
### Bug Fixes
|
||||
|
@ -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))
|
||||
|
||||
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
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
* `POST`: Create an object
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits import filters
|
||||
@ -7,7 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from dcim.api.views import PathEndpointMixin
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -25,7 +24,7 @@ class CircuitsRootView(APIRootView):
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
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
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
@ -37,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0)
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilterSet
|
||||
|
@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
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 .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
@ -67,7 +67,7 @@ class ProviderBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ProviderBulkEditView(generic.BulkEditView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
@ -76,7 +76,7 @@ class ProviderBulkEditView(generic.BulkEditView):
|
||||
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
@ -88,7 +88,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class CircuitTypeListView(generic.ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
@ -110,7 +110,7 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
@ -3,7 +3,6 @@ from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
@ -31,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
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 . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@ -120,12 +119,12 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.prefetch_related(
|
||||
'region', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=Coalesce(get_subquery(Device, 'site'), 0),
|
||||
rack_count=Coalesce(get_subquery(Rack, 'site'), 0),
|
||||
prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0),
|
||||
vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0),
|
||||
circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0),
|
||||
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0),
|
||||
device_count=count_related(Device, 'site'),
|
||||
rack_count=count_related(Rack, 'site'),
|
||||
prefix_count=count_related(Prefix, 'site'),
|
||||
vlan_count=count_related(VLAN, 'site'),
|
||||
circuit_count=count_related(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
|
||||
)
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilterSet
|
||||
@ -153,7 +152,7 @@ class RackGroupViewSet(ModelViewSet):
|
||||
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=Coalesce(get_subquery(Rack, 'role'), 0)
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilterSet
|
||||
@ -167,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group__site', 'role', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=Coalesce(get_subquery(Device, 'rack'), 0),
|
||||
powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0)
|
||||
device_count=count_related(Device, 'rack'),
|
||||
powerfeed_count=count_related(PowerFeed, 'rack')
|
||||
)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilterSet
|
||||
@ -241,9 +240,9 @@ class RackReservationViewSet(ModelViewSet):
|
||||
|
||||
class ManufacturerViewSet(ModelViewSet):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0),
|
||||
inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0),
|
||||
platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0)
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filters.ManufacturerFilterSet
|
||||
@ -255,7 +254,7 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
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
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
@ -319,8 +318,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=Coalesce(get_subquery(Device, 'device_role'), 0),
|
||||
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0)
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilterSet
|
||||
@ -332,8 +331,8 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=Coalesce(get_subquery(Device, 'platform'), 0),
|
||||
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0)
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filters.PlatformFilterSet
|
||||
@ -343,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@ -597,7 +596,7 @@ class CableViewSet(ModelViewSet):
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
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
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
@ -611,7 +610,7 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0)
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilterSet
|
||||
|
@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
choices=RackStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=RackTypeChoices
|
||||
)
|
||||
width = django_filters.MultipleChoiceFilter(
|
||||
choices=RackWidthChoices
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
|
||||
'outer_width', 'outer_depth', 'outer_unit',
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
label='User (ID)',
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user',
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label='User (name)',
|
||||
|
@ -21,7 +21,7 @@ from ipam.models import IPAddress, VLAN
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
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,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=RackTypeChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
width = forms.MultipleChoiceField(
|
||||
choices=RackWidthChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
role = DynamicModelMultipleChoiceField(
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
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(
|
||||
required=False,
|
||||
label='Search'
|
||||
@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
label='Rack group',
|
||||
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)
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
from cacheops import invalidate_obj
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.db import transaction
|
||||
@ -30,6 +31,7 @@ def rebuild_paths(obj):
|
||||
|
||||
with transaction.atomic():
|
||||
for cp in cable_paths:
|
||||
invalidate_obj(cp.origin)
|
||||
cp.delete()
|
||||
create_cablepath(cp.origin)
|
||||
|
||||
|
@ -447,7 +447,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'connection', 'actions',
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -329,7 +329,7 @@ class RackTestCase(TestCase):
|
||||
|
||||
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 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.objects.bulk_create(racks)
|
||||
@ -351,13 +351,11 @@ class RackTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': RackTypeChoices.TYPE_2POST}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_width(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'width': RackWidthChoices.WIDTH_19IN}
|
||||
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_u_height(self):
|
||||
@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
|
||||
users = User.objects.all()[:2]
|
||||
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
# TODO: Filtering by username is broken
|
||||
# params = {'user': [users[0].username, users[1].username]}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'user': [users[0].username, users[1].username]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
|
@ -20,7 +20,7 @@ from secrets.models import Secret
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
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 virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
@ -254,7 +254,7 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RackRoleListView(generic.ObjectListView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=get_subquery(Rack, 'role')
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
@ -276,7 +276,7 @@ class RackRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=get_subquery(Rack, 'role')
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
table = tables.RackRoleTable
|
||||
|
||||
@ -289,7 +289,7 @@ class RackListView(generic.ObjectListView):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'devices__device_type'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack')
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filters.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
@ -470,9 +470,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@ -494,7 +494,7 @@ class ManufacturerBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@ -505,7 +505,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceTypeListView(generic.ObjectListView):
|
||||
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_form = forms.DeviceTypeFilterForm
|
||||
@ -612,7 +612,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
|
||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
@ -621,7 +621,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
@ -913,8 +913,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceRoleListView(generic.ObjectListView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
vm_count=get_subquery(VirtualMachine, 'role')
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
@ -945,8 +945,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PlatformListView(generic.ObjectListView):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
vm_count=get_subquery(VirtualMachine, 'platform')
|
||||
device_count=count_related(Device, 'platform'),
|
||||
vm_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
table = tables.PlatformTable
|
||||
|
||||
@ -2335,7 +2335,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=get_subquery(Device, 'virtual_chassis')
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
table = tables.VirtualChassisTable
|
||||
filterset = filters.VirtualChassisFilterSet
|
||||
@ -2565,7 +2565,7 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
filterset_form = forms.PowerPanelFilterForm
|
||||
@ -2615,7 +2615,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
table = tables.PowerPanelTable
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
@ -22,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
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
|
||||
|
||||
|
||||
@ -103,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0)
|
||||
tagged_items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filters.TagFilterSet
|
||||
|
@ -2,6 +2,7 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.forms import DateField, IntegerField, NullBooleanField
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
def __init__(self, custom_field, *args, **kwargs):
|
||||
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)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
# Skip filter on empty value
|
||||
if value is None or not value.strip():
|
||||
return queryset
|
||||
|
||||
# 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)
|
||||
if custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
class CustomFieldModelFilterSet(django_filters.FilterSet):
|
||||
|
@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
cf.save()
|
||||
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):
|
||||
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||
form_data = {
|
||||
@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-1',
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'cf_my_field': 'ABC',
|
||||
'cf_my_field_select': 'Bar',
|
||||
'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].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_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-x',
|
||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||
'cf_my_field': 'DEF',
|
||||
'cf_my_field_select': 'Foo',
|
||||
'tags': [tags[2].pk],
|
||||
}
|
||||
|
||||
@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
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_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
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_select'], 'Bar')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
|
||||
@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
|
||||
cf.save()
|
||||
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
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 1'},
|
||||
@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
'my_field': 'DEF',
|
||||
'my_field_select': 'Foo',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 3'}
|
||||
@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
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'])
|
||||
|
@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.filters import SiteFilterSet
|
||||
from dcim.forms import SiteCSVForm
|
||||
from dcim.models import Site, Rack
|
||||
from extras.choices import *
|
||||
@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
site.cf['baz'] = 'def'
|
||||
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)
|
||||
|
@ -12,7 +12,7 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
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 . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
@ -27,7 +27,7 @@ from .scripts import get_scripts, run_script
|
||||
|
||||
class TagListView(generic.ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
filterset = filters.TagFilterSet
|
||||
filterset_form = forms.TagFilterForm
|
||||
@ -52,7 +52,7 @@ class TagBulkImportView(generic.BulkImportView):
|
||||
|
||||
class TagBulkEditView(generic.BulkEditView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
@ -60,7 +60,7 @@ class TagBulkEditView(generic.BulkEditView):
|
||||
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=get_subquery(TaggedItem, 'tag')
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
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 netbox.api.views import ModelViewSet
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -33,8 +32,8 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
|
||||
'import_targets', 'export_targets', 'tags'
|
||||
).annotate(
|
||||
ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0),
|
||||
prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0)
|
||||
ipaddress_count=count_related(IPAddress, 'vrf'),
|
||||
prefix_count=count_related(Prefix, 'vrf')
|
||||
)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilterSet
|
||||
@ -56,7 +55,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0)
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilterSet
|
||||
@ -78,8 +77,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0),
|
||||
vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0)
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilterSet
|
||||
@ -273,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
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
|
||||
filterset_class = filters.VLANGroupFilterSet
|
||||
@ -287,7 +286,7 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0)
|
||||
prefix_count=count_related(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
|
@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% if record.children %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a>
|
||||
{% else %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}9px">
|
||||
{% 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>
|
||||
</span>
|
||||
{% load helpers %}
|
||||
{% for i in record.parents|as_range %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<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>
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
|
||||
"""
|
||||
|
||||
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>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
|
@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
|
||||
from dcim.models import Device, Interface
|
||||
from netbox.views import generic
|
||||
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 . import filters, forms, tables
|
||||
from .constants import *
|
||||
@ -140,7 +140,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RIRListView(generic.ObjectListView):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=get_subquery(Aggregate, 'rir')
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
filterset_form = forms.RIRFilterForm
|
||||
@ -165,7 +165,7 @@ class RIRBulkImportView(generic.BulkImportView):
|
||||
|
||||
class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=get_subquery(Aggregate, 'rir')
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
filterset = filters.RIRFilterSet
|
||||
table = tables.RIRTable
|
||||
@ -277,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RoleListView(generic.ObjectListView):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
table = tables.RoleTable
|
||||
|
||||
@ -633,7 +633,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=get_subquery(VLAN, 'group')
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
filterset_form = forms.VLANGroupFilterForm
|
||||
@ -657,7 +657,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=get_subquery(VLAN, 'group')
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
|
@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
|
||||
# Text to include on the login page above the login form. HTML is allowed.
|
||||
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 = ''
|
||||
|
||||
@ -183,7 +183,7 @@ NAPALM_PASSWORD = ''
|
||||
# NAPALM timeout (in seconds). (Default: 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.
|
||||
NAPALM_ARGS = {}
|
||||
|
||||
|
@ -23,7 +23,7 @@ from secrets.tables import SecretTable
|
||||
from tenancy.filters import TenantFilterSet
|
||||
from tenancy.models import Tenant
|
||||
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.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
|
||||
@ -33,7 +33,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
@ -74,7 +74,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=get_subquery(Device, 'device_type')
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
@ -90,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=get_subquery(Device, 'virtual_chassis')
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
@ -111,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.10.1'
|
||||
VERSION = '2.10.2'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -1,11 +1,10 @@
|
||||
// Inteface filtering
|
||||
$('input.interface-filter').on('input', function() {
|
||||
var filter = new RegExp(this.value);
|
||||
var interface;
|
||||
let filter = new RegExp(this.value);
|
||||
let interface;
|
||||
|
||||
for (interface of $('table > tbody > tr')) {
|
||||
// Slice off 'interface_' at the start of the ID
|
||||
if (filter.test(interface.id.slice(10))) {
|
||||
if (filter.test(interface.getAttribute('data-name'))) {
|
||||
// Match the toggle in case the filter now matches the interface
|
||||
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
|
||||
$(interface).show();
|
||||
|
@ -1,7 +1,6 @@
|
||||
import base64
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponseBadRequest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@ -13,7 +12,7 @@ from netbox.api.views import ModelViewSet
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@ -36,7 +35,7 @@ class SecretsRootView(APIRootView):
|
||||
|
||||
class SecretRoleViewSet(ModelViewSet):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=Coalesce(get_subquery(Secret, 'role'), 0)
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
filterset_class = filters.SecretRoleFilterSet
|
||||
|
@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
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.")
|
||||
|
@ -7,7 +7,7 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .models import SecretRole, Secret, SessionKey, UserKey
|
||||
|
||||
@ -28,7 +28,7 @@ def get_session_key(request):
|
||||
|
||||
class SecretRoleListView(generic.ObjectListView):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=get_subquery(Secret, 'role')
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
table = tables.SecretRoleTable
|
||||
|
||||
@ -50,7 +50,7 @@ class SecretRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
class SecretRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=get_subquery(Secret, 'role')
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
table = tables.SecretRoleTable
|
||||
|
||||
|
@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<div class="col-xs-4 text-right noprint">
|
||||
<p class="text-muted">
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> ·
|
||||
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>
|
||||
|
@ -330,12 +330,16 @@
|
||||
<div class="col-md-6">
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
<h4>Front</h4>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
<h4>Rear</h4>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
@ -25,7 +25,8 @@
|
||||
{% if page %}
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% 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">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
@ -43,6 +44,7 @@
|
||||
<small class="text-muted">({{ rack.facility_id }})</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -137,7 +137,7 @@
|
||||
<td>
|
||||
{% if object.physical_address %}
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
@ -156,7 +156,7 @@
|
||||
<td>
|
||||
{% if object.latitude and object.longitude %}
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
@ -78,7 +78,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
<td>{{ vrf.description|placeholder }}</td>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% if secrets %}
|
||||
<form id="secret_form">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for secret in secrets %}
|
||||
<tr>
|
||||
|
@ -317,5 +317,6 @@
|
||||
|
||||
{% block javascript %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
@ -1,4 +1,3 @@
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits.models import Circuit
|
||||
@ -8,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
|
||||
from netbox.api.views import ModelViewSet
|
||||
from tenancy import filters
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
|
||||
@ -45,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
|
||||
queryset = Tenant.objects.prefetch_related(
|
||||
'group', 'tags'
|
||||
).annotate(
|
||||
circuit_count=get_subquery(Circuit, 'tenant'),
|
||||
device_count=get_subquery(Device, 'tenant'),
|
||||
ipaddress_count=Coalesce(get_subquery(IPAddress, 'tenant'), 0),
|
||||
prefix_count=Coalesce(get_subquery(Prefix, 'tenant'), 0),
|
||||
rack_count=Coalesce(get_subquery(Rack, 'tenant'), 0),
|
||||
site_count=Coalesce(get_subquery(Site, 'tenant'), 0),
|
||||
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'tenant'), 0),
|
||||
vlan_count=Coalesce(get_subquery(VLAN, 'tenant'), 0),
|
||||
vrf_count=Coalesce(get_subquery(VRF, 'tenant'), 0)
|
||||
circuit_count=count_related(Circuit, 'tenant'),
|
||||
device_count=count_related(Device, 'tenant'),
|
||||
ipaddress_count=count_related(IPAddress, 'tenant'),
|
||||
prefix_count=count_related(Prefix, 'tenant'),
|
||||
rack_count=count_related(Rack, 'tenant'),
|
||||
site_count=count_related(Site, 'tenant'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'tenant'),
|
||||
vlan_count=count_related(VLAN, 'tenant'),
|
||||
vrf_count=count_related(VRF, 'tenant')
|
||||
)
|
||||
serializer_class = serializers.TenantSerializer
|
||||
filterset_class = filters.TenantFilterSet
|
||||
|
@ -208,6 +208,18 @@ def split(string, 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
|
||||
#
|
||||
|
@ -5,6 +5,7 @@ from itertools import count, groupby
|
||||
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from jinja2 import Environment
|
||||
|
||||
from dcim.choices import CableLengthUnitChoices
|
||||
@ -65,7 +66,7 @@ def dynamic_import(name):
|
||||
return mod
|
||||
|
||||
|
||||
def get_subquery(model, field):
|
||||
def count_related(model, field):
|
||||
"""
|
||||
Return a Subquery suitable for annotating a child object count.
|
||||
"""
|
||||
@ -79,7 +80,7 @@ def get_subquery(model, field):
|
||||
).values('c')
|
||||
)
|
||||
|
||||
return subquery
|
||||
return Coalesce(subquery, 0)
|
||||
|
||||
|
||||
def serialize_object(obj, extra=None, exclude=None):
|
||||
|
@ -1,9 +1,8 @@
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from dcim.models import Device
|
||||
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.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from . import serializers
|
||||
@ -23,7 +22,7 @@ class VirtualizationRootView(APIRootView):
|
||||
|
||||
class ClusterTypeViewSet(ModelViewSet):
|
||||
queryset = ClusterType.objects.annotate(
|
||||
cluster_count=Coalesce(get_subquery(Cluster, 'type'), 0)
|
||||
cluster_count=count_related(Cluster, 'type')
|
||||
)
|
||||
serializer_class = serializers.ClusterTypeSerializer
|
||||
filterset_class = filters.ClusterTypeFilterSet
|
||||
@ -31,7 +30,7 @@ class ClusterTypeViewSet(ModelViewSet):
|
||||
|
||||
class ClusterGroupViewSet(ModelViewSet):
|
||||
queryset = ClusterGroup.objects.annotate(
|
||||
cluster_count=Coalesce(get_subquery(Cluster, 'group'), 0)
|
||||
cluster_count=count_related(Cluster, 'group')
|
||||
)
|
||||
serializer_class = serializers.ClusterGroupSerializer
|
||||
filterset_class = filters.ClusterGroupFilterSet
|
||||
@ -41,8 +40,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
queryset = Cluster.objects.prefetch_related(
|
||||
'type', 'group', 'tenant', 'site', 'tags'
|
||||
).annotate(
|
||||
device_count=Coalesce(get_subquery(Device, 'cluster'), 0),
|
||||
virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster'), 0)
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'cluster')
|
||||
)
|
||||
serializer_class = serializers.ClusterSerializer
|
||||
filterset_class = filters.ClusterFilterSet
|
||||
@ -52,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
queryset = VirtualMachine.objects.prefetch_related(
|
||||
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
|
||||
)
|
||||
|
@ -183,3 +183,6 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from netbox.views import generic
|
||||
from secrets.models import Secret
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
@ -22,7 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
|
||||
|
||||
class ClusterTypeListView(generic.ObjectListView):
|
||||
queryset = ClusterType.objects.annotate(
|
||||
cluster_count=get_subquery(Cluster, 'type')
|
||||
cluster_count=count_related(Cluster, 'type')
|
||||
)
|
||||
table = tables.ClusterTypeTable
|
||||
|
||||
@ -44,7 +44,7 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ClusterType.objects.annotate(
|
||||
cluster_count=get_subquery(Cluster, 'type')
|
||||
cluster_count=count_related(Cluster, 'type')
|
||||
)
|
||||
table = tables.ClusterTypeTable
|
||||
|
||||
@ -55,7 +55,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ClusterGroupListView(generic.ObjectListView):
|
||||
queryset = ClusterGroup.objects.annotate(
|
||||
cluster_count=get_subquery(Cluster, 'group')
|
||||
cluster_count=count_related(Cluster, 'group')
|
||||
)
|
||||
table = tables.ClusterGroupTable
|
||||
|
||||
@ -77,7 +77,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ClusterGroup.objects.annotate(
|
||||
cluster_count=get_subquery(Cluster, 'group')
|
||||
cluster_count=count_related(Cluster, 'group')
|
||||
)
|
||||
table = tables.ClusterGroupTable
|
||||
|
||||
@ -89,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
class ClusterListView(generic.ObjectListView):
|
||||
permission_required = 'virtualization.view_cluster'
|
||||
queryset = Cluster.objects.annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
)
|
||||
table = tables.ClusterTable
|
||||
filterset = filters.ClusterFilterSet
|
||||
|
Loading…
Reference in New Issue
Block a user