Compare commits

...

61 Commits

Author SHA1 Message Date
Jeremy Stretch
5a32b9599a Merge pull request #5581 from netbox-community/develop
Release v2.10.3
2021-01-05 21:09:50 -05:00
Jeremy Stretch
a6cb7965dc Merge branch 'master' into develop 2021-01-05 20:36:14 -05:00
Jeremy Stretch
601cbd2306 Release v2.10.3 2021-01-05 20:33:46 -05:00
Jeremy Stretch
a77658a6bf Merge pull request #5580 from nemith/apidocfix
Fixes #5579: mark `ie` and `nie` filter exprs as insensitive
2021-01-05 20:31:49 -05:00
Brandon Bennett
4a2d2882c6 Fixes #5579: mark ie and nie filter exprs as insensitive
In the documentation the `ie` and `nie` filter expressions are
incorrectly marked as senstive matches when they are in fact
insensitive.
2021-01-05 15:51:03 -07:00
Jeremy Stretch
0accaedad0 Fixes #5569: Ensure consistent labeling of interface mgmt_only field 2021-01-05 15:15:36 -05:00
Jeremy Stretch
aa10430c7b Changelog for #5573 2021-01-05 15:06:15 -05:00
Jeremy Stretch
98983e7e1a Merge pull request #5578 from candlerb/candlerb-5573
Report inconsistent values when migrating custom field data
2021-01-05 15:00:20 -05:00
Daniel Sheppard
3441216aca Fixes: #5564 - Raise validation error if a PowerPortTemplate's draw exceeds maximum 2021-01-05 09:52:11 -06:00
Daniel Sheppard
d16a7e108c Fixes: #5563 - Fix power feed cable trace 2021-01-05 09:22:10 -06:00
Brian Candler
359ae5d116 Raise exceptions for other inconsistencies when migrating custom field data
Fixes #5573
2021-01-05 15:05:17 +00:00
Daniel Sheppard
a9a2509d39 Fixes: #5049 - Account for chassis neighbors in lldp_neighbors template 2021-01-05 08:48:55 -06:00
Jeremy Stretch
e73c225965 Fixes #5551: Restore missing import button on services list 2020-12-31 10:38:47 -05:00
Jeremy Stretch
39e6872288 Fixes #5557: Fix VRF route target assignment via REST API 2020-12-31 10:32:40 -05:00
Jeremy Stretch
af3c4905ea Fixes #5558: Fix regex validation support for custom URL fields 2020-12-31 10:21:01 -05:00
Jeremy Stretch
7873952e7a Avoid wrapping text in hierarchical table columns 2020-12-30 15:30:31 -05:00
Jeremy Stretch
d989ce2b70 Fixes #5547: Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis 2020-12-29 12:43:52 -05:00
Jeremy Stretch
249948e174 Fixes #5546: Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis 2020-12-29 11:55:31 -05:00
Jeremy Stretch
8ae3331d04 Closes #5549: Eliminate extraneous database queries when using brief API calls 2020-12-29 11:41:44 -05:00
Jeremy Stretch
b2e05aafc1 Closes #5531: Ensure consistent calls to parent clean() methods for models, forms 2020-12-28 12:54:42 -05:00
Jeremy Stretch
cc1a43e5d9 Fixes #5533: Fix bulk editing of objects with required custom fields 2020-12-28 11:10:27 -05:00
Jeremy Stretch
6f39e6599d Fixes #5540: Fix exception when viewing a provider with one or more tags assigned 2020-12-28 10:48:50 -05:00
Jeremy Stretch
1fe5857411 Fixes #5543: Fix rendering of config contexts with cluster assignment for devices 2020-12-28 10:45:23 -05:00
Jeremy Stretch
fce61295c9 Fixes #5301: Fix misleading error when racking a device with invalid parameters 2020-12-23 16:21:26 -05:00
Jeremy Stretch
396b0dace8 Changelog for #5311 2020-12-23 14:26:43 -05:00
Jeremy Stretch
fe2e33a9e1 Merge pull request #5522 from netbox-community/5311-site-rack-validation
Employ signals to update child objects when RackGroup/Rack site assignment changes
2020-12-23 14:24:56 -05:00
Jeremy Stretch
8d9d4cec05 Extend handle_rackgroup_site_change() receiver to update power panels 2020-12-23 14:02:05 -05:00
Jeremy Stretch
ddd10ba8af Clean up hierarchical table columns 2020-12-22 16:14:16 -05:00
Jeremy Stretch
e4f22bc494 Employ signals to update child objects when RackGroup/Rack site assignment changes 2020-12-22 15:22:53 -05:00
Jeremy Stretch
09633ee11b Move rack group field directly beneath site 2020-12-22 13:51:58 -05:00
Jeremy Stretch
dc6dbdf3c4 Update documentation to reference the feature branch 2020-12-22 13:12:38 -05:00
Jeremy Stretch
8f4197c020 Fixes #5518: Fix persistent vertical scrollbar 2020-12-22 10:28:09 -05:00
Jeremy Stretch
5fe5fd71b5 PRVB 2020-12-21 16:19:40 -05:00
Jeremy Stretch
77e2b0e4ba Merge pull request #5512 from netbox-community/develop
Release v2.10.2
2020-12-21 16:17:49 -05:00
Jeremy Stretch
e196d82e76 Release v2.10.2 2020-12-21 16:03:43 -05:00
Jeremy Stretch
23f334e5df Fixes #5488: Fix caching error when viewing cable trace after toggling cable status 2020-12-21 15:30:34 -05:00
Robin Schneider
c5a265e828 Use HTTPS URL schema everywhere (#5505)
* Use HTTPS everywhere (mechanical edit using util from https-everywhere)

```Shell
node ~/src/EFForg/https-everywhere/utils/rewriter/rewriter.js .
git checkout netbox/project-static/
```

A few additional changes where reset manually before the commit.

* Use HTTPS everywhere (mechanical edit using util from opening_hours.js)

```Shell
make -f ~/src/opening-hours/opening_hours.js/Makefile qa-https-everywhere
git checkout netbox/project-static/
git checkout netbox/*/tests
```
2020-12-21 12:02:30 -05:00
Jeremy Stretch
278c881059 as_range: Catch TypeErrors 2020-12-21 11:54:35 -05:00
Jeremy Stretch
b09112941a Fixes #5510: Fix filtering by boolean custom fields 2020-12-21 11:47:11 -05:00
Jeremy Stretch
158ed5765b Fixes #5507: Fix custom field data assignment via UI for IP addresses, secrets 2020-12-21 09:43:15 -05:00
Jeremy Stretch
1e5778e6db Clean up prefix hierarchy annotation 2020-12-18 15:43:15 -05:00
Jeremy Stretch
442dee72b5 Fixes #5499: Fix filtering of displayed device/VM interfaces by regex 2020-12-18 15:02:52 -05:00
Daniel Sheppard
f2d028ba29 Update version-2.10.md 2020-12-18 11:33:28 -06:00
Jeremy Stretch
b1402f3bae Fixes #5498: Fix filtering rack reservations by username 2020-12-18 12:18:59 -05:00
Jeremy Stretch
6ca9e2a777 Closes #5496: Add form field to filter rack reservation by user 2020-12-18 12:09:41 -05:00
Daniel Sheppard
dac93b9840 Fixes #5254 - Require plugin authors to set zip_safe=False 2020-12-17 16:18:10 -06:00
Jeremy Stretch
34a0e2e72d Closes #5489: Add filters for type and width to racks list 2020-12-17 15:55:22 -05:00
Jeremy Stretch
af2777c580 Fixes #5487: Support filtering rack type/width with multiple values 2020-12-17 15:50:47 -05:00
Jeremy Stretch
4a066a1913 Rename get_subquery() to count_related() 2020-12-17 14:47:49 -05:00
Jeremy Stretch
91083fd6c5 Call Coalesce() inside get_queryset() 2020-12-17 14:45:50 -05:00
Jeremy Stretch
202e7d0ebf Fixes #5486: Optimize retrieval of config context data for device/VM REST API views 2020-12-17 14:03:10 -05:00
Jeremy Stretch
7449f36f22 Merge pull request #5485 from glennmatthews/gfm-doc-fix
Docs fix: Django templating language is no longer supported for export templates
2020-12-17 10:06:03 -05:00
Glenn Matthews
dc6e7cd56c Django templating language is no longer supported for export templates 2020-12-17 09:48:22 -05:00
Jeremy Stretch
26426c1431 Fixes #5484: Fix "tagged" indication in VLAN members list 2020-12-17 09:46:50 -05:00
Jeremy Stretch
2fba20acda Merge pull request #5481 from cloos/5123-cf-select-changelog
Fixes #5123: Add tests for custom field select changelog
2020-12-16 14:29:27 -05:00
Jeremy Stretch
3c887b0dd9 Fixes #5468: Fix unlocking secrets from device/VM view 2020-12-16 13:48:44 -05:00
Jeremy Stretch
52c9e3ecef Run CI for pull requests 2020-12-16 13:23:45 -05:00
Christian Loos
2ffdc46f81 Fixes #5123: Add tests for custom field select changelog 2020-12-16 18:50:55 +01:00
Jeremy Stretch
577c86fb80 Fixes #5478: Fix display of route target description 2020-12-16 10:38:09 -05:00
Jeremy Stretch
4ce7dfa55e Fixes #5473: Fix alignment of rack names in elevations list 2020-12-16 09:26:22 -05:00
Jeremy Stretch
bd144c996a PRVB 2020-12-15 22:16:26 -05:00
78 changed files with 681 additions and 313 deletions

View File

@@ -1,5 +1,5 @@
name: CI
on: push
on: [push, pull_request]
jobs:
build:
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.
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`.

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.
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:

View File

@@ -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 = {

View File

@@ -27,13 +27,13 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
```
The NetBox project utilizes three long-term branches:
The NetBox project utilizes three persistent git branches to track work:
* `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable release occurs here
* `develop-x.y` - Tracks work on an upcoming major release
* `feature` - Tracks work on an upcoming major release
Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
### Enable Pre-Commit Hooks

View File

@@ -52,10 +52,7 @@ Close the release milestone on GitHub after ensuring there are no remaining open
### Merge the Release Branch
Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
!!! warning
No further releases for the current major version can be published once this pull request is merged.
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
---

View File

@@ -83,7 +83,7 @@ Checking connectivity... done.
```
!!! note
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
## Create the NetBox System User

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

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))
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

View File

@@ -1,5 +1,52 @@
# NetBox v2.10
## v2.10.3 (2021-01-05)
### Bug Fixes
* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors
* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields
* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned
* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices
* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis
* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis
* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list
* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API
* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields
* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link
* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw`
* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field
* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data
---
## 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

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))
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

View File

@@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
- `nisw` - negated case insensitive starts with
- `iew` - case insensitive ends with
- `niew` - negated case insensitive ends with
- `ie` - case sensitive exact match
- `nie` - negated case sensitive exact match
- `ie` - case insensitive exact match
- `nie` - negated case insensitive exact match
### Foreign Keys & Other Fields

View File

@@ -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

View File

@@ -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
@@ -66,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet
brief_prefetch_fields = ['circuit']

View File

@@ -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

View File

@@ -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,10 +254,11 @@ 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
brief_prefetch_fields = ['manufacturer']
#
@@ -319,8 +319,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 +332,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 +343,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',
@@ -494,6 +494,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilterSet
brief_prefetch_fields = ['device']
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
@@ -502,18 +503,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilterSet
brief_prefetch_fields = ['device']
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilterSet
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilterSet
brief_prefetch_fields = ['device']
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
@@ -522,30 +526,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet
brief_prefetch_fields = ['device']
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet
brief_prefetch_fields = ['device']
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet
brief_prefetch_fields = ['device']
class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer
filterset_class = filters.DeviceBayFilterSet
brief_prefetch_fields = ['device']
class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filters.InventoryItemFilterSet
brief_prefetch_fields = ['device']
#
@@ -597,10 +606,11 @@ 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
brief_prefetch_fields = ['master']
#
@@ -611,7 +621,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

View File

@@ -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)',

View File

@@ -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,
@@ -134,6 +134,7 @@ class ComponentForm(BootstrapMixin, forms.Form):
)
def clean(self):
super().clean()
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
if self.cleaned_data['label_pattern']:
@@ -690,6 +691,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',
@@ -773,7 +784,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
]
class RackReservationCSVForm(CSVModelForm):
class RackReservationCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -823,7 +834,7 @@ class RackReservationCSVForm(CSVModelForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -850,7 +861,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 +885,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)
@@ -1419,6 +1439,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
@@ -1762,9 +1783,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'group_id': '$rack_group',
}
)
position = forms.TypedChoiceField(
position = forms.IntegerField(
required=False,
empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
@@ -1837,6 +1857,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
"config context",
}
widgets = {
'face': StaticSelect2(),
'status': StaticSelect2(),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@@ -1883,6 +1904,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
)
# Disable rack assignment if this is a child device installed in a parent device
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
else:
# An object that doesn't exist yet can't have any IPs assigned to it
@@ -1892,31 +1920,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Rack position
pk = self.instance.pk if self.instance.pk else None
try:
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack']) \
.get_rack_units(face=self.data.get('face'), exclude=pk)
elif self.initial.get('rack') and str(self.initial.get('face')):
position_choices = Rack.objects.get(pk=self.initial['rack']) \
.get_rack_units(face=self.initial.get('face'), exclude=pk)
else:
position_choices = []
except Rack.DoesNotExist:
position_choices = []
self.fields['position'].choices = [('', '---------')] + [
(p['id'], {
'label': p['name'],
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
}) for p in position_choices
]
# Disable rack assignment if this is a child device installed in a parent device
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
position = self.data.get('position') or self.initial.get('position')
if position:
self.fields['position'].widget.choices = [(position, f'U{position}')]
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
@@ -2925,6 +2931,7 @@ class InterfaceBulkEditForm(
self.fields['lag'].widget.attrs['disabled'] = True
def clean(self):
super().clean()
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
@@ -3073,6 +3080,7 @@ class FrontPortCreateForm(ComponentCreateForm):
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
@@ -3767,7 +3775,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
}
class CableCSVForm(CSVModelForm):
class CableCSVForm(CustomFieldModelCSVForm):
# Termination A
side_a_device = CSVModelChoiceField(
queryset=Device.objects.all(),
@@ -3862,7 +3870,7 @@ class CableCSVForm(CSVModelForm):
return length_unit if length_unit is not None else ''
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@@ -3905,6 +3913,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
]
def clean(self):
super().clean()
# Validate length/unit
length = self.cleaned_data.get('length')
@@ -4248,7 +4257,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
return device
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -4262,7 +4271,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
nullable_fields = ['domain']
class VirtualChassisCSVForm(CSVModelForm):
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
master = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
@@ -4349,7 +4358,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
]
class PowerPanelCSVForm(CSVModelForm):
class PowerPanelCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -4375,7 +4384,7 @@ class PowerPanelCSVForm(CSVModelForm):
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
@@ -4403,9 +4412,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
)
class Meta:
nullable_fields = (
'rack_group',
)
nullable_fields = ['rack_group']
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

@@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
allocated_draw=self.allocated_draw
)
def clean(self):
super().clean()
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
class PowerOutletTemplate(ComponentTemplateModel):
"""
@@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def clean(self):
super().clean()
# Validate power port assignment
if self.power_port and self.power_port.device_type != self.device_type:
@@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:

View File

@@ -316,6 +316,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
)
def clean(self):
super().clean()
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
@@ -425,6 +426,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
)
def clean(self):
super().clean()
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
@@ -503,7 +505,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
verbose_name='Management only',
help_text='This interface is used only for out-of-band management'
)
untagged_vlan = models.ForeignKey(
@@ -555,6 +557,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
)
def clean(self):
super().clean()
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
@@ -668,6 +671,7 @@ class FrontPort(CableTermination, ComponentModel):
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device != self.device:
@@ -711,6 +715,7 @@ class RearPort(CableTermination, ComponentModel):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Check that positions count is greater than or equal to the number of associated FrontPorts
frontport_count = self.frontports.count()
@@ -768,6 +773,7 @@ class DeviceBay(ComponentModel):
)
def clean(self):
super().clean()
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:

View File

@@ -640,7 +640,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
if self.rack is None:
@@ -650,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
'position': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
@@ -662,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Prevent 0U devices from being assigned to a specific position
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
})
if self.rack:
@@ -688,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
'position': f"U{self.position} is already occupied or does not have sufficient space to "
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
})
except DeviceType.DoesNotExist:

View File

@@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
)
def clean(self):
super().clean()
# Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
@@ -326,22 +327,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
'group': "Rack group must be from the same site, {}.".format(self.site)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super().save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
devices = Device.objects.filter(rack=self)
for device in devices:
device.site = self.site
device.save()
def to_csv(self):
return (
self.site.name,

View File

@@ -1,12 +1,13 @@
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
from django.dispatch import receiver
from .choices import CableStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
def create_cablepath(node):
@@ -30,10 +31,48 @@ def rebuild_paths(obj):
with transaction.atomic():
for cp in cable_paths:
invalidate_obj(cp.origin)
cp.delete()
create_cablepath(cp.origin)
#
# Site/rack/device assignment
#
@receiver(post_save, sender=RackGroup)
def handle_rackgroup_site_change(instance, created, **kwargs):
"""
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
"""
if not created:
for rackgroup in instance.get_children():
rackgroup.site = instance.site
rackgroup.save()
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
rack.site = instance.site
rack.save()
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
powerpanel.site = instance.site
powerpanel.save()
@receiver(post_save, sender=Rack)
def handle_rack_site_change(instance, created, **kwargs):
"""
Update child Devices if Site assignment has changed.
"""
if not created:
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
device.site = instance.site
device.save()
#
# Virtual chassis
#
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
@@ -58,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
device.save()
#
# Cables
#
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs):
"""

View File

@@ -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,
}

View File

@@ -26,7 +26,8 @@ class RackGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site = tables.LinkColumn(
viewname='dcim:site',

View File

@@ -19,7 +19,8 @@ class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site_count = tables.Column(
verbose_name='Sites'

View File

@@ -57,13 +57,10 @@ INTERFACE_TAGGED_VLANS = """
"""
MPTT_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
</span>
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
POWERFEED_CABLE = """

View File

@@ -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]

View File

@@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_non_racked_device_with_face_position(self):
def test_non_racked_device_with_face(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
@@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
'site': Site.objects.first().pk,
'rack': None,
'face': DeviceFaceChoices.FACE_REAR,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('face', form.errors)
def test_non_racked_device_with_position(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('position', form.errors)

View File

@@ -7,6 +7,42 @@ from dcim.models import *
from tenancy.models import Tenant
class RackGroupTestCase(TestCase):
def test_change_rackgroup_site(self):
"""
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
Site A
- RackGroup A1
- RackGroup A2
- Rack 2
- Rack 1
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
rackgroup_a1.save()
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
rackgroup_a2.save()
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
# Move RackGroup A1 to Site B
rackgroup_a1.site = site_b
rackgroup_a1.save()
# Check that all objects within RackGroup A1 now belong to Site B
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
class RackTestCase(TestCase):
def setUp(self):
@@ -154,6 +190,34 @@ class RackTestCase(TestCase):
)
self.assertTrue(pdu)
def test_change_rack_site(self):
"""
Check that child Devices get updated when a Rack is moved to a new Site.
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
# Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
# Move Rack1 to Site B
rack1.site = site_b
rack1.save()
# Check that Device1 is now assigned to Site B
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
class DeviceTestCase(TestCase):

View File

@@ -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

View File

@@ -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
@@ -40,7 +39,6 @@ class ConfigContextQuerySetMixin:
Provides a get_queryset() method which deals with adding the config context
data annotation or not.
"""
def get_queryset(self):
"""
Build the proper queryset based on the request context
@@ -50,11 +48,11 @@ class ConfigContextQuerySetMixin:
Else, return the queryset annotated with config context data
"""
queryset = super().get_queryset()
request = self.get_serializer_context()['request']
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
return self.queryset
return self.queryset.annotate_config_context_data()
if self.brief or 'config_context' in request.query_params.get('exclude', []):
return queryset
return queryset.annotate_config_context_data()
#
@@ -103,7 +101,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

View File

@@ -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):

View File

@@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
try:
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
except ValueError as e:
except Exception as e:
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
raise e
model.objects.filter(pk=cfv.obj_id).update(**cf_data)

View File

@@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
])
def clean(self):
super().clean()
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
# Validate all field values
@@ -172,6 +174,8 @@ class CustomField(models.Model):
obj.save()
def clean(self):
super().clean()
# Validate the field's default value (if any)
if self.default is not None:
try:
@@ -192,7 +196,8 @@ class CustomField(models.Model):
})
# Regex validation can be set only for text fields
if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL)
if self.validation_regex and self.type not in regex_types:
raise ValidationError({
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})

View File

@@ -117,11 +117,15 @@ class Webhook(models.Model):
return self.name
def clean(self):
super().clean()
# At least one action type must be selected
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError(
"You must select at least one type: create, update, and/or delete."
)
# CA file path requires SSL verification enabled
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
@@ -436,6 +440,7 @@ class ConfigContext(ChangeLoggedModel):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
@@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
return data
def clean(self):
super().clean()
# Verify that JSON data is provided as an object

View File

@@ -89,6 +89,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
}
base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
Q(
@@ -111,8 +113,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
region_field = 'cluster__site__region'

View File

@@ -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'])

View File

@@ -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)

View File

@@ -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

View File

@@ -25,8 +25,18 @@ from .nested_serializers import *
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
import_targets = SerializedPKRelatedField(
queryset=RouteTarget.objects.all(),
serializer=NestedRouteTargetSerializer,
required=False,
many=True
)
export_targets = SerializedPKRelatedField(
queryset=RouteTarget.objects.all(),
serializer=NestedRouteTargetSerializer,
required=False,
many=True
)
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)

View File

@@ -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

View File

@@ -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'):

View File

@@ -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>
@@ -272,7 +270,7 @@ class PrefixTable(BaseTable):
pk = ToggleColumn()
prefix = tables.TemplateColumn(
template_code=PREFIX_LINK,
attrs={'th': {'style': 'padding-left: 17px'}}
attrs={'td': {'class': 'text-nowrap'}}
)
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL

View File

@@ -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
@@ -804,7 +804,7 @@ class ServiceListView(generic.ObjectListView):
filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
action_buttons = ('export',)
action_buttons = ('import', 'export')
class ServiceView(generic.ObjectView):

View File

@@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django_rq.queues import get_connection
from rest_framework import mixins, status
from rest_framework import status
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
from rq.worker import Worker
from netbox.api import BulkOperationSerializer
@@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
# Viewsets
#
class ModelViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
BulkUpdateModelMixin,
BulkDestroyModelMixin,
GenericViewSet):
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
"""
Accept either a single object or a list of objects to create.
Extend DRF's ModelViewSet to support bulk update and delete functions.
"""
brief = False
brief_prefetch_fields = []
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
@@ -142,22 +138,34 @@ class ModelViewSet(mixins.CreateModelMixin,
def get_serializer_class(self):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
# exists
request = self.get_serializer_context()['request']
if request.query_params.get('brief'):
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
logger.debug(f"Using serializer {serializer}")
return serializer
except SerializerNotFound:
pass
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
# Fall back to the hard-coded serializer class
logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
def get_queryset(self):
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
if self.brief:
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return super().get_queryset()
def initialize_request(self, request, *args, **kwargs):
# Check if brief=True has been passed
if request.method == 'GET' and request.GET.get('brief'):
self.brief = True
return super().initialize_request(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)

View File

@@ -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 = {}

View File

@@ -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,

View File

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

View File

@@ -798,8 +798,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Update custom fields
for name in custom_fields:
if name in form.nullable_fields and name in nullified_fields:
obj.custom_field_data.pop(name, None)
else:
obj.custom_field_data[name] = None
elif form.cleaned_data.get(name) not in (None, ''):
obj.custom_field_data[name] = form.cleaned_data[name]
obj.full_clean()

View File

@@ -14,21 +14,21 @@ body {
.wrapper {
min-height: 100%;
height: auto !important;
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */
padding-bottom: 30px;
}
.navbar-brand {
padding: 12px 15px 8px;
}
.footer, .push {
height: 60px; /* .push must be the same height as .footer */
height: 48px; /* .push must be the same height as .footer */
}
.footer {
background-color: #f5f5f5;
border-top: 1px solid #d0d0d0;
}
footer p {
margin: 20px 0;
margin: 12px 0;
}
#navbar_search {
padding: 0 8px;

View File

@@ -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();

View File

@@ -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

View File

@@ -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.")

View File

@@ -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

View File

@@ -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> &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-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>

View File

@@ -100,7 +100,7 @@
</table>
</div>
{% include 'inc/custom_fields_panel.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>

View File

@@ -23,7 +23,7 @@
<tr id="{{ iface.name }}">
<td>{{ iface }}</td>
{% if iface.connected_endpoint.device %}
<td class="configured_device" data="{{ iface.connected_endpoint.device }}">
<td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
</td>
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
@@ -61,6 +61,7 @@ $(document).ready(function() {
// Glean configured hostnames/interfaces from the DOM
var configured_device = row.children('td.configured_device').attr('data');
var configured_chassis = row.children('td.configured_device').attr('data-chassis');
var configured_interface = row.children('td.configured_interface').attr('data');
var configured_interface_short = null;
if (configured_interface) {
@@ -81,9 +82,9 @@ $(document).ready(function() {
// Apply colors to rows
if (!configured_device && lldp_device) {
row.addClass('info');
} else if (configured_device == lldp_device && configured_interface == lldp_interface) {
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface == lldp_interface) {
row.addClass('success');
} else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface_short == lldp_interface) {
row.addClass('success');
} else {
row.addClass('danger');

View File

@@ -165,7 +165,7 @@
<td>Cable</td>
<td>
<a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
<a href="{% url 'dcim:powerfeed_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td>

View File

@@ -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">

View File

@@ -7,9 +7,9 @@
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.group %}
{% render_field form.name %}
{% render_field form.facility_id %}
{% render_field form.group %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.serial %}

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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

View File

@@ -4,13 +4,10 @@ from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagCol
from .models import Tenant, TenantGroup
MPTT_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
</span>
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
COL_TENANT = """
@@ -30,7 +27,8 @@ class TenantGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
tenant_count = LinkedCountColumn(
viewname='tenancy:tenant_list',

View File

@@ -169,6 +169,8 @@ class ObjectPermissionForm(forms.ModelForm):
self.instance.actions.remove(action)
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data.get('constraints')

View File

@@ -82,6 +82,7 @@ class BulkRenameForm(forms.Form):
)
def clean(self):
super().clean()
# Validate regular expression in "find" field
if self.cleaned_data['use_regex']:
@@ -124,6 +125,7 @@ class ImportForm(BootstrapMixin, forms.Form):
)
def clean(self):
super().clean()
data = self.cleaned_data['data']
format = self.cleaned_data['format']

View File

@@ -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
#

View File

@@ -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):

View File

@@ -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'
)
@@ -85,3 +84,4 @@ class VMInterfaceViewSet(ModelViewSet):
)
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine']

View File

@@ -444,6 +444,7 @@ class VMInterface(BaseInterface):
)
def clean(self):
super().clean()
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:

View File

@@ -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,
}

View File

@@ -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