Merge remote-tracking branch 'True-NetBox/develop' into develop-2.10

This commit is contained in:
Nikolay Ogorov 2020-12-22 17:42:44 +03:00
commit 8de4cbbf50
99 changed files with 1212 additions and 577 deletions

View File

@ -7,11 +7,9 @@ about: Report a reproducible bug in the current release of NetBox
<!-- <!--
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED. NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for reproducible bugs. If you need assistance with This form is only for reporting reproducible bugs. If you need assistance
NetBox installation, or if you have a general question, DO NOT open an with NetBox installation, or if you have a general question, please start a
issue. Instead, post to our mailing list: discussion instead: https://github.com/netbox-community/netbox/discussions
https://groups.google.com/g/netbox-discuss
Please describe the environment in which you are running NetBox. Be sure Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release that you are running an unmodified instance of the latest stable release

View File

@ -8,10 +8,8 @@ about: Propose a new NetBox feature or enhancement
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED. NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for proposing specific new features or enhancements. This form is only for proposing specific new features or enhancements.
If you have a general idea or question, please post to our mailing list If you have a general idea or question, please start a discussion instead:
instead of opening an issue: https://github.com/netbox-community/netbox/discussions
https://groups.google.com/g/netbox-discuss
NOTE: Due to an excessive backlog of feature requests, we are not currently NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope. accepting any proposals which significantly extend NetBox's feature scope.

50
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
services:
redis:
image: redis
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies & set up configuration
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 netbox/
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*

View File

@ -1,20 +0,0 @@
sudo: required
services:
- postgresql
- redis-server
addons:
postgresql: "9.6"
language: python
python:
- "3.6"
- "3.7"
- "3.8"
install:
- pip install -r requirements.txt
- pip install pycodestyle
- pip install coverage
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'
script:
- ./scripts/cibuild.sh

View File

@ -4,15 +4,31 @@ If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub issue following resources to get assistance. Please **do not** open a GitHub issue
except to report bugs or request features. except to report bugs or request features.
### GitHub Discussions
GitHub's discussions are the best place to get help or propose rough ideas for
new functionality. Their integration with GitHub allows for easily cross-
referencing and converting posts to issues as needed. There are several
categories for discussions:
* **General** - General community discussion
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request
* **Q&A** - Request help with installing or using NetBox
### Mailing List ### Mailing List
We have established a Google Groups Mailing List for issues and general We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
discussion. This is the best forum for obtaining assistance with NetBox for general discussion, however we're encouraging people to use GitHub
installation. You can find us [here](https://groups.google.com/g/netbox-discuss). discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack ### Slack
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/). For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
## Reporting Bugs ## Reporting Bugs
@ -171,7 +187,7 @@ overlooked.
* Official channels for communication include: * Official channels for communication include:
* GitHub issues/pull requests * GitHub issues, pull requests, and discussions
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list * The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/) * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)

View File

@ -7,20 +7,20 @@ to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations. function as a domain-specific source of truth for network operations.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss), Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)! or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status ### Build Status
| | status | | | status |
|-------------|------------| |-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=master)](https://travis-ci.com/netbox-community/netbox/) | | **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
| **develop** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=develop)](https://travis-ci.com/netbox-community/netbox/) | | **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
### Screenshots ### Screenshots
@ -36,19 +36,22 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
## Installation ## Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) instructions on installing NetBox. To upgrade NetBox, please download the
and run `upgrade.sh`. [latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`.
## Providing Feedback ## Providing Feedback
Feature requests and bug reports must be submitted as GiHub issues. (Please be The best platform for general feedback, assistance, and other discussion is our
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).) [GitHub discussions](https://github.com/netbox-community/netbox/discussions).
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss). To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
If you are interested in contributing to the development of NetBox, please read If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work. our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
## Related projects ## Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects. Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

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

View File

@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
Default: None Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set: The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
```python ```python
BASE_PATH = 'netbox/' BASE_PATH = 'netbox/'
@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
## NAPALM_ARGS ## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python ```python
NAPALM_ARGS = { NAPALM_ARGS = {
@ -460,7 +460,7 @@ NetBox can be configured to support remote user authentication by inferring user
Default: `'HTTP_REMOTE_USER'` Default: `'HTTP_REMOTE_USER'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.) When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
--- ---

View File

@ -7,7 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
Communication among developers should always occur via public channels: Communication among developers should always occur via public channels:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. * [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. * [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
## Governance ## Governance

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,46 @@
# NetBox v2.10 # NetBox v2.10
## v2.10-beta2 (FUTURE) ## v2.10.2 (2020-12-21)
### Enhancements ### Enhancements
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields * [#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 ### Bug Fixes
* [#5176](https://github.com/netbox-community/netbox/issues/5176) - Enforce content type restrictions when creating objects via the REST API * [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
* [#5358](https://github.com/netbox-community/netbox/issues/5358) - Fix user table configuration for VM interfaces * [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
* [#5374](https://github.com/netbox-community/netbox/issues/5374) - Fix exception thrown when tracing mid-point * [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
* [#5376](https://github.com/netbox-community/netbox/issues/5376) - Correct invalid custom field filter logic values * [#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-beta1 (2020-11-17) ## v2.10.1 (2020-12-15)
### Bug Fixes
* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces
* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value
* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable
* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception
* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception
* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception
* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device
* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields
* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint
---
## v2.10.0 (2020-12-14)
**NOTE:** This release completely removes support for embedded graphs. **NOTE:** This release completely removes support for embedded graphs.
@ -89,12 +114,17 @@ All end-to-end cable paths are now cached using the new CablePath backend model.
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis
* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window * [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window
* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`) * [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`)
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view
### Other Changes ### Other Changes
* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy * [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy
* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs * [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates * [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method * [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method
* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models * [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models
* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list * [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list

View File

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

View File

@ -1,5 +1,26 @@
# NetBox v2.9 # NetBox v2.9
## v2.9.11 (2020-12-11)
### Enhancements
* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command`
* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types
### Bug Fixes
* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API
* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names
* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned
* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list
* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext
* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms
* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported)
* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list
* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings
---
## v2.9.10 (2020-11-24) ## v2.9.10 (2020-11-24)
### Enhancements ### Enhancements

View File

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

View File

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

View File

@ -1,12 +1,12 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -17,7 +17,9 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# #
class ProviderListView(generic.ObjectListView): class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering) queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable table = tables.ProviderTable
@ -64,14 +66,18 @@ class ProviderBulkImportView(generic.BulkImportView):
class ProviderBulkEditView(generic.BulkEditView): class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering) queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
form = forms.ProviderBulkEditForm form = forms.ProviderBulkEditForm
class ProviderBulkDeleteView(generic.BulkDeleteView): class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering) queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -81,7 +87,9 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
# #
class CircuitTypeListView(generic.ObjectListView): class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering) queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
@ -101,7 +109,9 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering) queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
@ -129,7 +139,7 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
if termination_a and termination_a.connected_endpoint: if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination # Z-side termination
@ -138,7 +148,7 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).first()
if termination_z and termination_z.connected_endpoint: if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return { return {

View File

@ -584,22 +584,15 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
'count_ipaddresses', 'count_ipaddresses',
] ]
# TODO: This validation should be handled by Interface.clean()
def validate(self, data): def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site. # Validate many-to-many VLAN assignments
device = self.instance.device if self.instance else data.get('device') device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []): for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]: if vlan.site not in [device.site, None]:
raise serializers.ValidationError({ raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must " 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
"be global.".format(vlan) f"it must be global."
}) })
return super().validate(data) return super().validate(data)
@ -773,11 +766,10 @@ class CablePathSerializer(serializers.ModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.ListField) @swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_path(self, obj): def get_path(self, obj):
ret = [] ret = []
for node in obj.path: for node in obj.get_path():
ct_id, object_id = decompile_path_node(node) serializer = get_serializer_for_model(node, prefix='Nested')
ct = ContentType.objects.get_for_id(ct_id) context = {'request': self.context['request']}
# TODO: Return the object URL ret.append(serializer(node, context=context).data)
ret.append(f'{ct.app_label}.{ct.model}:{object_id}')
return ret return ret

View File

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

View File

@ -887,6 +887,8 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH = 'lsh' TYPE_LSH = 'lsh'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
CHOICES = ( CHOICES = (
( (
@ -914,6 +916,8 @@ class PortTypeChoices(ChoiceSet):
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),
(TYPE_SC_APC, 'SC/APC'), (TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'), (TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_SPLICE, 'Splice'), (TYPE_SPLICE, 'Splice'),
) )
) )

View File

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

View File

@ -21,10 +21,11 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
) )
class InterfaceCommonForm: class InterfaceCommonForm(forms.Form):
def clean(self): def clean(self):
super().clean() super().clean()
# Validate VLAN assignments parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data['tagged_vlans'] tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs # Untagged interfaces cannot be assigned tagged VLANs
@ -109,13 +109,13 @@ class InterfaceCommonForm:
# Validate tagged VLANs; must be a global VLAN or in the same site # Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
valid_sites = [None, self.cleaned_data['device'].site] valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
if invalid_vlans: if invalid_vlans:
raise forms.ValidationError({ raise forms.ValidationError({
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent " 'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
"device/VM, or they must be global".format(', '.join(invalid_vlans)) f"the interface's parent device/VM, or they must be global"
}) })
@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False, required=False,
widget=StaticSelect2Multiple() widget=StaticSelect2Multiple()
) )
type = forms.MultipleChoiceField(
choices=RackTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False,
widget=StaticSelect2Multiple()
)
role = DynamicModelMultipleChoiceField( role = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
to_field_name='slug', to_field_name='slug',
@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation model = RackReservation
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant'] field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
required=False, required=False,
label='Search' label='Search'
@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
label='Rack group', label='Rack group',
null_option='None' null_option='None'
) )
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -922,7 +941,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
'front_image', 'rear_image', 'comments', 'tags', 'front_image', 'rear_image', 'comments', 'tags',
] ]
widgets = { widgets = {
'subdevice_role': StaticSelect2() 'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
'front_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
}),
'rear_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
})
} }
@ -2681,7 +2707,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
@ -2832,7 +2858,7 @@ class InterfaceBulkCreateForm(
class InterfaceBulkEditForm( class InterfaceBulkEditForm(
form_from_model(Interface, [ form_from_model(Interface, [
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' 'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
]), ]),
BootstrapMixin, BootstrapMixin,
AddRemoveTagsForm, AddRemoveTagsForm,
@ -2848,6 +2874,15 @@ class InterfaceBulkEditForm(
disabled=True, disabled=True,
widget=forms.HiddenInput() widget=forms.HiddenInput()
) )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label='Management only'
)
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
@ -3758,10 +3793,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name', to_field_name='name',
help_text='Side A device' help_text='Side A device'
) )
side_a_type = CSVModelChoiceField( side_a_type = CSVContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side A type' help_text='Side A type'
) )
side_a_name = forms.CharField( side_a_name = forms.CharField(
@ -3774,10 +3808,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name', to_field_name='name',
help_text='Side B device' help_text='Side B device'
) )
side_b_type = CSVModelChoiceField( side_b_type = CSVContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side B type' help_text='Side B type'
) )
side_b_name = forms.CharField( side_b_name = forms.CharField(
@ -3811,58 +3844,36 @@ class CableCSVForm(CSVModelForm):
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'), 'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
} }
# TODO: Merge the clean() methods for either end def _clean_side(self, side):
def clean_side_a_name(self): """
Derive a Cable's A/B termination objects.
device = self.cleaned_data.get('side_a_device') :param side: 'a' or 'b'
content_type = self.cleaned_data.get('side_a_type') """
name = self.cleaned_data.get('side_a_name') assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name: if not device or not content_type or not name:
return None return None
model = content_type.model_class() model = content_type.model_class()
try: try:
termination_object = model.objects.get( termination_object = model.objects.get(device=device, name=name)
device=device,
name=name
)
if termination_object.cable is not None: if termination_object.cable is not None:
raise forms.ValidationError( raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
"Side A: {} {} is already connected".format(device, termination_object)
)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError( raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
"A side termination not found: {} {}".format(device, name)
)
self.instance.termination_a = termination_object setattr(self.instance, f'termination_{side}', termination_object)
return termination_object return termination_object
def clean_side_a_name(self):
return self._clean_side('a')
def clean_side_b_name(self): def clean_side_b_name(self):
return self._clean_side('b')
device = self.cleaned_data.get('side_b_device')
content_type = self.cleaned_data.get('side_b_type')
name = self.cleaned_data.get('side_b_name')
if not device or not content_type or not name:
return None
model = content_type.model_class()
try:
termination_object = model.objects.get(
device=device,
name=name
)
if termination_object.cable is not None:
raise forms.ValidationError(
"Side B: {} {} is already connected".format(device, termination_object)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
"B side termination not found: {} {}".format(device, name)
)
self.instance.termination_b = termination_object
return termination_object
def clean_length_unit(self): def clean_length_unit(self):
# Avoid trying to save as NULL # Avoid trying to save as NULL

View File

@ -30,6 +30,13 @@ class Command(BaseCommand):
help="Do not prompt user for any input/confirmation" help="Do not prompt user for any input/confirmation"
) )
def draw_progress_bar(self, percentage):
"""
Draw a simple progress bar 20 increments wide illustrating the specified percentage.
"""
bar_size = int(percentage / 5)
self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20-bar_size)}] {int(percentage)}%", ending='')
def handle(self, *model_names, **options): def handle(self, *model_names, **options):
# If --force was passed, first delete all existing CablePaths # If --force was passed, first delete all existing CablePaths
@ -67,15 +74,15 @@ class Command(BaseCommand):
origins = origins.filter(_path__isnull=True) origins = origins.filter(_path__isnull=True)
origins_count = origins.count() origins_count = origins.count()
if not origins_count: if not origins_count:
print(f'Found no missing {model._meta.verbose_name} paths; skipping') self.stdout.write(f'Found no missing {model._meta.verbose_name} paths; skipping')
continue continue
print(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...') self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0 i = 0
for i, obj in enumerate(origins, start=1): for i, obj in enumerate(origins, start=1):
create_cablepath(obj) create_cablepath(obj)
# TODO: Come up with a better progress indicator if not i % 100:
if not i % 1000: self.draw_progress_bar(i * 100 / origins_count)
self.stdout.write(f' {i}') self.draw_progress_bar(100)
self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}')) self.stdout.write(self.style.SUCCESS(f'\n Retraced {i} {model._meta.verbose_name_plural}'))
self.stdout.write(self.style.SUCCESS('Finished.')) self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
return instance return instance
def __str__(self): def __str__(self):
return self.label or '#{}'.format(self._pk) pk = self.pk or self._pk
return self.label or f'#{pk}'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk]) return reverse('dcim:cable', args=[self.pk])
@ -411,21 +412,27 @@ class CablePath(models.Model):
position_stack.append(peer_termination.rear_port_position) position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node)) path.append(object_to_path_node(node))
# Follow a RearPort to its corresponding FrontPort # Follow a RearPort to its corresponding FrontPort (if any)
elif isinstance(peer_termination, RearPort): elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination)) path.append(object_to_path_node(peer_termination))
# Determine the peer FrontPort's position
if peer_termination.positions == 1: if peer_termination.positions == 1:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1) position = 1
path.append(object_to_path_node(node))
elif position_stack: elif position_stack:
position = position_stack.pop() position = position_stack.pop()
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
else: else:
# No position indicated: path has split, so we stop at the RearPort # No position indicated: path has split, so we stop at the RearPort
is_split = True is_split = True
break break
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
break
# Anything else marks the end of the path # Anything else marks the end of the path
else: else:
destination = peer_termination destination = peer_termination

View File

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

View File

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

View File

@ -45,9 +45,15 @@ INTERFACE_IPADDRESSES = """
""" """
INTERFACE_TAGGED_VLANS = """ INTERFACE_TAGGED_VLANS = """
{% for vlan in record.tagged_vlans.unrestricted %} {% if record.mode == 'tagged' %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br /> {% for vlan in record.tagged_vlans.all %}
{% endfor %} <a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %}
{% elif record.mode == 'tagged-all' %}
All
{% else %}
&mdash;
{% endif %}
""" """
MPTT_LINK = """ MPTT_LINK = """

View File

@ -796,6 +796,30 @@ class CablePathTestCase(TestCase):
) )
self.assertEqual(CablePath.objects.count(), 2) self.assertEqual(CablePath.objects.count(), 2)
def test_207_rearport_without_frontport(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1, cable2, rearport2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
def test_301_create_path_via_existing_cable(self): def test_301_create_path_via_existing_cable(self):
""" """
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

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

View File

@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments', 'comments': 'New comments',
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_rack_elevations(self):
"""
Test viewing the list of rack elevations.
"""
response = self.client.get(reverse('dcim:rack_elevation_list'))
self.assertHttpStatus(response, 200)
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer model = Manufacturer
@ -1668,9 +1676,9 @@ class CableTestCase(
cls.csv_data = ( cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 3,interface,Interface 1,Device 4,interface,Interface 1", "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,interface,Interface 2,Device 4,interface,Interface 2", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,interface,Interface 3,Device 4,interface,Interface 3", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {

View File

@ -4,7 +4,7 @@ from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction from django.db import transaction
from django.db.models import Count, F, Prefetch from django.db.models import F, Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.html import escape from django.utils.html import escape
@ -20,7 +20,7 @@ from secrets.models import Secret
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, get_subquery from utilities.utils import csv_format, count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
@ -253,7 +253,9 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
# #
class RackRoleListView(generic.ObjectListView): class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering) queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
table = tables.RackRoleTable table = tables.RackRoleTable
@ -273,7 +275,9 @@ class RackRoleBulkImportView(generic.BulkImportView):
class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering) queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
table = tables.RackRoleTable table = tables.RackRoleTable
@ -282,9 +286,11 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
# #
class RackListView(generic.ObjectListView): class RackListView(generic.ObjectListView):
queryset = Rack.objects.annotate( queryset = Rack.objects.prefetch_related(
device_count=Count('devices') 'site', 'group', 'tenant', 'role', 'devices__device_type'
).order_by(*Rack._meta.ordering) ).annotate(
device_count=count_related(Device, 'rack')
)
filterset = filters.RackFilterSet filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm filterset_form = forms.RackFilterForm
table = tables.RackDetailTable table = tables.RackDetailTable
@ -467,9 +473,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView): class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'), devicetype_count=count_related(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer') platform_count=count_related(Platform, 'manufacturer')
) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -491,8 +497,8 @@ class ManufacturerBulkImportView(generic.BulkImportView):
class ManufacturerBulkDeleteView(generic.BulkDeleteView): class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
devicetype_count=Count('device_types') devicetype_count=count_related(DeviceType, 'manufacturer')
).order_by(*Manufacturer._meta.ordering) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -501,9 +507,9 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
# #
class DeviceTypeListView(generic.ObjectListView): class DeviceTypeListView(generic.ObjectListView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances') instance_count=count_related(Device, 'device_type')
).order_by(*DeviceType._meta.ordering) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@ -609,8 +615,8 @@ class DeviceTypeImportView(generic.ObjectImportView):
class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkEditView(generic.BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances') instance_count=count_related(Device, 'device_type')
).order_by(*DeviceType._meta.ordering) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm form = forms.DeviceTypeBulkEditForm
@ -618,8 +624,8 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances') instance_count=count_related(Device, 'device_type')
).order_by(*DeviceType._meta.ordering) )
filterset = filters.DeviceTypeFilterSet filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@ -910,8 +916,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceRoleListView(generic.ObjectListView): class DeviceRoleListView(generic.ObjectListView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'), device_count=count_related(Device, 'device_role'),
vm_count=get_subquery(VirtualMachine, 'role') vm_count=count_related(VirtualMachine, 'role')
) )
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
@ -942,8 +948,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
class PlatformListView(generic.ObjectListView): class PlatformListView(generic.ObjectListView):
queryset = Platform.objects.annotate( queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'), device_count=count_related(Device, 'platform'),
vm_count=get_subquery(VirtualMachine, 'platform') vm_count=count_related(VirtualMachine, 'platform')
) )
table = tables.PlatformTable table = tables.PlatformTable
@ -1331,6 +1337,11 @@ class ConsolePortListView(generic.ObjectListView):
class ConsolePortView(generic.ObjectView): class ConsolePortView(generic.ObjectView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_consoleports'
}
class ConsolePortCreateView(generic.ComponentCreateView): class ConsolePortCreateView(generic.ComponentCreateView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
@ -1391,6 +1402,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
class ConsoleServerPortView(generic.ObjectView): class ConsoleServerPortView(generic.ObjectView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_consoleserverports'
}
class ConsoleServerPortCreateView(generic.ComponentCreateView): class ConsoleServerPortCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
@ -1451,6 +1467,11 @@ class PowerPortListView(generic.ObjectListView):
class PowerPortView(generic.ObjectView): class PowerPortView(generic.ObjectView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_powerports'
}
class PowerPortCreateView(generic.ComponentCreateView): class PowerPortCreateView(generic.ComponentCreateView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
@ -1511,6 +1532,11 @@ class PowerOutletListView(generic.ObjectListView):
class PowerOutletView(generic.ObjectView): class PowerOutletView(generic.ObjectView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_poweroutlets'
}
class PowerOutletCreateView(generic.ComponentCreateView): class PowerOutletCreateView(generic.ComponentCreateView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
@ -1595,6 +1621,7 @@ class InterfaceView(generic.ObjectView):
return { return {
'ipaddress_table': ipaddress_table, 'ipaddress_table': ipaddress_table,
'vlan_table': vlan_table, 'vlan_table': vlan_table,
'breadcrumb_url': 'dcim:device_interfaces'
} }
@ -1657,6 +1684,11 @@ class FrontPortListView(generic.ObjectListView):
class FrontPortView(generic.ObjectView): class FrontPortView(generic.ObjectView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_frontports'
}
class FrontPortCreateView(generic.ComponentCreateView): class FrontPortCreateView(generic.ComponentCreateView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
@ -1717,6 +1749,11 @@ class RearPortListView(generic.ObjectListView):
class RearPortView(generic.ObjectView): class RearPortView(generic.ObjectView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_rearports'
}
class RearPortCreateView(generic.ComponentCreateView): class RearPortCreateView(generic.ComponentCreateView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
@ -1777,6 +1814,11 @@ class DeviceBayListView(generic.ObjectListView):
class DeviceBayView(generic.ObjectView): class DeviceBayView(generic.ObjectView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_devicebays'
}
class DeviceBayCreateView(generic.ComponentCreateView): class DeviceBayCreateView(generic.ComponentCreateView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
@ -1898,6 +1940,11 @@ class InventoryItemListView(generic.ObjectListView):
class InventoryItemView(generic.ObjectView): class InventoryItemView(generic.ObjectView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
def get_extra_context(self, request, instance):
return {
'breadcrumb_url': 'dcim:device_inventory'
}
class InventoryItemEditView(generic.ObjectEditView): class InventoryItemEditView(generic.ObjectEditView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
@ -2290,9 +2337,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
# #
class VirtualChassisListView(generic.ObjectListView): class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.annotate( queryset = VirtualChassis.objects.prefetch_related('master').annotate(
member_count=Count('members', distinct=True) member_count=count_related(Device, 'virtual_chassis')
).order_by(*VirtualChassis._meta.ordering) )
table = tables.VirtualChassisTable table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm filterset_form = forms.VirtualChassisFilterForm
@ -2518,9 +2565,11 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
# #
class PowerPanelListView(generic.ObjectListView): class PowerPanelListView(generic.ObjectListView):
queryset = PowerPanel.objects.annotate( queryset = PowerPanel.objects.prefetch_related(
powerfeed_count=Count('powerfeeds') 'site', 'rack_group'
).order_by(*PowerPanel._meta.ordering) ).annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filters.PowerPanelFilterSet filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm filterset_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable table = tables.PowerPanelTable
@ -2569,8 +2618,8 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related( queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group' 'site', 'rack_group'
).annotate( ).annotate(
rack_count=Count('powerfeeds') powerfeed_count=count_related(PowerFeed, 'power_panel')
).order_by(*PowerPanel._meta.ordering) )
filterset = filters.PowerPanelFilterSet filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable table = tables.PowerPanelTable

View File

@ -2,7 +2,6 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from utilities.forms import LaxURLField from utilities.forms import LaxURLField
from .choices import CustomFieldTypeChoices
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
@ -30,8 +29,8 @@ class WebhookForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'obj_type' in self.fields: if 'content_types' in self.fields:
order_content_types(self.fields['obj_type']) order_content_types(self.fields['content_types'])
@admin.register(Webhook) @admin.register(Webhook)
@ -41,12 +40,12 @@ class WebhookAdmin(admin.ModelAdmin):
'ssl_verification', 'ssl_verification',
] ]
list_filter = [ list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type', 'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
] ]
form = WebhookForm form = WebhookForm
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('name', 'obj_type', 'enabled') 'fields': ('name', 'content_types', 'enabled')
}), }),
('Events', { ('Events', {
'fields': ('type_create', 'type_update', 'type_delete') 'fields': ('type_create', 'type_update', 'type_delete')
@ -63,7 +62,7 @@ class WebhookAdmin(admin.ModelAdmin):
) )
def models(self, obj): def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()]) return ', '.join([ct.name for ct in obj.content_types.all()])
# #
@ -76,6 +75,7 @@ class CustomFieldForm(forms.ModelForm):
model = CustomField model = CustomField
exclude = [] exclude = []
widgets = { widgets = {
'default': forms.TextInput(),
'validation_regex': forms.Textarea( 'validation_regex': forms.Textarea(
attrs={ attrs={
'cols': 80, 'cols': 80,

View File

@ -26,15 +26,8 @@ class CustomFieldDefaultValues:
# Populate the default value for each CustomField # Populate the default value for each CustomField
value = {} value = {}
for field in fields: for field in fields:
if field.default: if field.default is not None:
if field.type == CustomFieldTypeChoices.TYPE_INTEGER: value[field.name] = field.default
field_value = int(field.default)
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
# TODO: Fix default value assignment for boolean custom fields
field_value = False if field.default.lower() == 'false' else bool(field.default)
else:
field_value = field.default
value[field.name] = field_value
else: else:
value[field.name] = None value[field.name] = None

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -12,15 +11,17 @@ from rq import Worker
from extras import filters from extras import filters
from extras.choices import JobResultStatusChoices from extras.choices import JobResultStatusChoices
from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag from extras.models import (
ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
)
from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script from extras.scripts import get_script, get_scripts, run_script
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.querysets import RestrictedQuerySet from utilities.utils import copy_safe_request, count_related
from utilities.utils import copy_safe_request
from . import serializers from . import serializers
@ -101,8 +102,8 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet): class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
tagged_items=Count('extras_taggeditem_items') tagged_items=count_related(TaggedItem, 'tag')
).order_by(*Tag._meta.ordering) )
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet filterset_class = filters.TagFilterSet

View File

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

View File

@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name) self.custom_fields.append(field_name)
def save(self, commit=True): def clean(self):
# Save custom field data on instance # Save custom field data on instance
for cf_name in self.custom_fields: for cf_name in self.custom_fields:
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
return super().save(commit) return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):

View File

@ -25,12 +25,18 @@ class Command(BaseCommand):
help = "Start the Django shell with all NetBox models already imported" help = "Start the Django shell with all NetBox models already imported"
django_models = {} django_models = {}
def add_arguments(self, parser):
parser.add_argument(
'-c', '--command',
help='Python code to execute (instead of starting an interactive shell)',
)
def _lsmodels(self): def _lsmodels(self):
for app, models in self.django_models.items(): for app, models in self.django_models.items():
app_name = apps.get_app_config(app).verbose_name app_name = apps.get_app_config(app).verbose_name
print('{}:'.format(app_name)) print(f'{app_name}:')
for m in models: for m in models:
print(' {}'.format(m)) print(f' {m}')
def get_namespace(self): def get_namespace(self):
namespace = {} namespace = {}
@ -46,7 +52,7 @@ class Command(BaseCommand):
# Constants # Constants
try: try:
app_constants = sys.modules['{}.constants'.format(app)] app_constants = sys.modules[f'{app}.constants']
for name in dir(app_constants): for name in dir(app_constants):
namespace[name] = getattr(app_constants, name) namespace[name] = getattr(app_constants, name)
except KeyError: except KeyError:
@ -64,5 +70,10 @@ class Command(BaseCommand):
return namespace return namespace
def handle(self, **options): def handle(self, **options):
# If Python code has been passed, execute it and exit.
if options['command']:
exec(options['command'], self.get_namespace())
return
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace()) shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
return shell return shell

View File

@ -34,6 +34,12 @@ class Migration(migrations.Migration):
size=None size=None
), ),
), ),
# Introduce new default field (to be renamed later)
migrations.AddField(
model_name='customfield',
name='default2',
field=models.JSONField(blank=True, null=True),
),
# Rename obj_type to content_types # Rename obj_type to content_types
migrations.RenameField( migrations.RenameField(
model_name='customfield', model_name='customfield',

View File

@ -16,6 +16,28 @@ def deserialize_value(field, value):
return value return value
def migrate_customfield_defaults(apps, schema_editor):
"""
Copy old serialized defaults to native JSON types.
"""
CustomField = apps.get_model('extras', 'CustomField')
for customfield in CustomField.objects.exclude(default=''):
try:
if customfield.type == CustomFieldTypeChoices.TYPE_INTEGER:
value = int(customfield.default)
elif customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
value = customfield.default in ['true', 'yes', '1']
else:
value = customfield.default
except ValueError:
raise ValueError(
f'Invalid default value "{customfield.default}" found for {customfield.type} '
f'custom field {customfield.name}'
)
CustomField.objects.filter(pk=customfield.pk).update(default2=value)
def migrate_customfieldchoices(apps, schema_editor): def migrate_customfieldchoices(apps, schema_editor):
""" """
Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
@ -73,6 +95,9 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(
code=migrate_customfield_defaults
),
migrations.RunPython( migrations.RunPython(
code=migrate_customfieldchoices code=migrate_customfieldchoices
), ),

View File

@ -8,6 +8,15 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RemoveField(
model_name='CustomField',
name='default',
),
migrations.RenameField(
model_name='CustomField',
old_name='default2',
new_name='default'
),
migrations.DeleteModel( migrations.DeleteModel(
name='CustomFieldChoice', name='CustomFieldChoice',
), ),

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-12-02 19:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0052_customfield_cleanup'),
]
operations = [
migrations.RenameField(
model_name='webhook',
old_name='obj_type',
new_name='content_types',
),
]

View File

@ -1,6 +1,6 @@
import re import re
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime from datetime import datetime, date
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -115,10 +115,11 @@ class CustomField(models.Model):
help_text='Loose matches any instance of a given string; exact ' help_text='Loose matches any instance of a given string; exact '
'matches the entire field.' 'matches the entire field.'
) )
default = models.CharField( default = models.JSONField(
max_length=100,
blank=True, blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.' null=True,
help_text='Default value for the field (must be a JSON value). Encapsulate '
'strings with double quotes (e.g. "Foo").'
) )
weight = models.PositiveSmallIntegerField( weight = models.PositiveSmallIntegerField(
default=100, default=100,
@ -171,6 +172,15 @@ class CustomField(models.Model):
obj.save() obj.save()
def clean(self): def clean(self):
# Validate the field's default value (if any)
if self.default is not None:
try:
self.validate(self.default)
except ValidationError as err:
raise ValidationError({
'default': f'Invalid default value "{self.default}": {err.message}'
})
# Minimum/maximum values can be set only for numeric fields # Minimum/maximum values can be set only for numeric fields
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
raise ValidationError({ raise ValidationError({
@ -232,8 +242,6 @@ class CustomField(models.Model):
(True, 'True'), (True, 'True'),
(False, 'False'), (False, 'False'),
) )
if initial is not None:
initial = bool(initial)
field = forms.NullBooleanField( field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices) required=required, initial=initial, widget=StaticSelect2(choices=choices)
) )
@ -309,10 +317,11 @@ class CustomField(models.Model):
# Validate date # Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE: if self.type == CustomFieldTypeChoices.TYPE_DATE:
try: if type(value) is not date:
datetime.strptime(value, '%Y-%m-%d') try:
except ValueError: datetime.strptime(value, '%Y-%m-%d')
raise ValidationError("Date values must be in the format YYYY-MM-DD.") except ValueError:
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
# Validate selected choice # Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT: if self.type == CustomFieldTypeChoices.TYPE_SELECT:

View File

@ -31,8 +31,7 @@ class Webhook(models.Model):
delete in NetBox. The request will contain a representation of the object, which the remote application can act on. delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
Each Webhook can be limited to firing only on certain actions or certain object types. Each Webhook can be limited to firing only on certain actions or certain object types.
""" """
# TODO: Rename obj_type to content_types (see #4711) content_types = models.ManyToManyField(
obj_type = models.ManyToManyField(
to=ContentType, to=ContentType,
related_name='webhooks', related_name='webhooks',
verbose_name='Object types', verbose_name='Object types',

View File

@ -3,7 +3,6 @@ import inspect
from packaging import version from packaging import version
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template from django.template.loader import get_template
@ -71,10 +70,10 @@ class PluginConfig(AppConfig):
register_menu_items(self.verbose_name, menu_items) register_menu_items(self.verbose_name, menu_items)
@classmethod @classmethod
def validate(cls, user_config): def validate(cls, user_config, netbox_version):
# Enforce version constraints # Enforce version constraints
current_version = version.parse(settings.VERSION) current_version = version.parse(netbox_version)
if cls.min_version is not None: if cls.min_version is not None:
min_version = version.parse(cls.min_version) min_version = version.parse(cls.min_version)
if current_version < min_version: if current_version < min_version:

View File

@ -1,5 +1,6 @@
from django.db.models import OuterRef, Subquery, Q from django.db.models import OuterRef, Subquery, Q
from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -81,11 +82,25 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
def _get_config_context_filters(self): def _get_config_context_filters(self):
# Construct the set of Q objects for the specific object types # Construct the set of Q objects for the specific object types
tag_query_filters = {
"object_id": OuterRef(OuterRef('pk')),
"content_type__app_label": self.model._meta.app_label,
"content_type__model": self.model._meta.model_name
}
base_query = Q( base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None), Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None), Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None), Q(tenants=OuterRef('tenant')) | Q(tenants=None),
Q(tags=OuterRef('tags')) | Q(tags=None), Q(
tags__pk__in=Subquery(
TaggedItem.objects.filter(
**tag_query_filters
).values_list(
'tag_id',
flat=True
)
)
) | Q(tags=None),
is_active=True, is_active=True,
) )

View File

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

View File

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

View File

@ -321,3 +321,46 @@ class ConfigContextTest(TestCase):
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data() annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1) self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context()) self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
This is combatted by by appending distinct() to the config context querysets. This test creates a config
context assigned to two tags and ensures objects related by those same two tags result in only a single
config context record being returned.
This test case is seperate from the above in that it deals with multiple config context objects in play.
See https://github.com/netbox-community/netbox/issues/5387
"""
tag_context_1 = ConfigContext.objects.create(
name="tag-1",
weight=100,
data={
"tag": 1
}
)
tag_context_1.tags.add(self.tag)
tag_context_2 = ConfigContext.objects.create(
name="tag-2",
weight=100,
data={
"tag": 1
}
)
tag_context_2.tags.add(self.tag2)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())

View File

@ -86,21 +86,19 @@ class PluginTest(TestCase):
""" """
self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS) self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS)
@override_settings(VERSION='0.9')
def test_min_version(self): def test_min_version(self):
""" """
Check enforcement of minimum NetBox version. Check enforcement of minimum NetBox version.
""" """
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({}) dummy_config.validate({}, '0.9')
@override_settings(VERSION='10.0')
def test_max_version(self): def test_max_version(self):
""" """
Check enforcement of maximum NetBox version. Check enforcement of maximum NetBox version.
""" """
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({}) dummy_config.validate({}, '10.0')
def test_required_settings(self): def test_required_settings(self):
""" """
@ -110,11 +108,11 @@ class PluginTest(TestCase):
required_settings = ['foo'] required_settings = ['foo']
# Validation should pass when all required settings are present # Validation should pass when all required settings are present
DummyConfigWithRequiredSettings.validate({'foo': True}) DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION)
# Validation should fail when a required setting is missing # Validation should fail when a required setting is missing
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
DummyConfigWithRequiredSettings.validate({}) DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
def test_default_settings(self): def test_default_settings(self):
""" """
@ -127,10 +125,10 @@ class PluginTest(TestCase):
# Populate the default value if setting has not been specified # Populate the default value if setting has not been specified
user_config = {} user_config = {}
DummyConfigWithDefaultSettings.validate(user_config) DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 123) self.assertEqual(user_config['bar'], 123)
# Don't overwrite specified values # Don't overwrite specified values
user_config = {'bar': 456} user_config = {'bar': 456}
DummyConfigWithDefaultSettings.validate(user_config) DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 456) self.assertEqual(user_config['bar'], 456)

View File

@ -39,7 +39,7 @@ class WebhookTest(APITestCase):
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
)) ))
for webhook in webhooks: for webhook in webhooks:
webhook.obj_type.set([site_ct]) webhook.content_types.set([site_ct])
def test_enqueue_webhook_create(self): def test_enqueue_webhook_create(self):
# Create an object via the REST API # Create an object via the REST API

View File

@ -1,7 +1,7 @@
from django import template from django import template
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View from django.views.generic import View
@ -12,11 +12,11 @@ from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin
from . import filters, forms, tables from . import filters, forms, tables
from .choices import JobResultStatusChoices from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
from .reports import get_report, get_reports, run_report from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script from .scripts import get_scripts, run_script
@ -27,8 +27,8 @@ from .scripts import get_scripts, run_script
class TagListView(generic.ObjectListView): class TagListView(generic.ObjectListView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items') items=count_related(TaggedItem, 'tag')
).order_by(*Tag._meta.ordering) )
filterset = filters.TagFilterSet filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm filterset_form = forms.TagFilterForm
table = tables.TagTable table = tables.TagTable
@ -52,16 +52,16 @@ class TagBulkImportView(generic.BulkImportView):
class TagBulkEditView(generic.BulkEditView): class TagBulkEditView(generic.BulkEditView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items') items=count_related(TaggedItem, 'tag')
).order_by(*Tag._meta.ordering) )
table = tables.TagTable table = tables.TagTable
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
class TagBulkDeleteView(generic.BulkDeleteView): class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items') items=count_related(TaggedItem, 'tag')
).order_by(*Tag._meta.ordering) )
table = tables.TagTable table = tables.TagTable

View File

@ -35,13 +35,13 @@ def enqueue_webhooks(instance, user, request_id, action):
return return
# Retrieve any applicable Webhooks # Retrieve any applicable Webhooks
obj_type = ContentType.objects.get_for_model(instance) content_type = ContentType.objects.get_for_model(instance)
action_flag = { action_flag = {
ObjectChangeActionChoices.ACTION_CREATE: 'type_create', ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update', ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete', ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
}[action] }[action]
webhooks = Webhook.objects.filter(obj_type=obj_type, enabled=True, **{action_flag: True}) webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True})
if webhooks.exists(): if webhooks.exists():
# Get the Model's API serializer class and serialize the object # Get the Model's API serializer class and serialize the object

View File

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

View File

@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
def clean(self): def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface # Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
@ -856,6 +857,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
) )
status = CSVChoiceField( status = CSVChoiceField(
choices=IPAddressStatusChoices, choices=IPAddressStatusChoices,
required=False,
help_text='Operational status' help_text='Operational status'
) )
role = CSVChoiceField( role = CSVChoiceField(
@ -888,7 +890,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = IPAddress.csv_headers fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'dns_name', 'description',
]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)

View File

@ -10,7 +10,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tenancy', '0010_custom_field_data'), ('tenancy', '0010_custom_field_data'),
('extras', '0052_delete_customfieldchoice_customfieldvalue'), ('extras', '0052_customfield_cleanup'),
('ipam', '0040_service_drop_port'), ('ipam', '0040_service_drop_port'),
] ]

View File

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

View File

@ -1,4 +1,4 @@
from django.db.models import Count, Prefetch from django.db.models import Prefetch
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
from dcim.models import Device, Interface from dcim.models import Device, Interface
from netbox.views import generic from netbox.views import generic
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables from . import filters, forms, tables
from .constants import * from .constants import *
@ -139,7 +139,9 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
# #
class RIRListView(generic.ObjectListView): class RIRListView(generic.ObjectListView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering) queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm filterset_form = forms.RIRFilterForm
table = tables.RIRTable table = tables.RIRTable
@ -162,7 +164,9 @@ class RIRBulkImportView(generic.BulkImportView):
class RIRBulkDeleteView(generic.BulkDeleteView): class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering) queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
table = tables.RIRTable table = tables.RIRTable
@ -174,7 +178,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
class AggregateListView(generic.ObjectListView): class AggregateListView(generic.ObjectListView):
queryset = Aggregate.objects.annotate( queryset = Aggregate.objects.annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
).order_by(*Aggregate._meta.ordering) )
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
filterset_form = forms.AggregateFilterForm filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable table = tables.AggregateDetailTable
@ -273,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
class RoleListView(generic.ObjectListView): class RoleListView(generic.ObjectListView):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role') vlan_count=count_related(VLAN, 'role')
) )
table = tables.RoleTable table = tables.RoleTable
@ -628,9 +632,9 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
# #
class VLANGroupListView(generic.ObjectListView): class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans') vlan_count=count_related(VLAN, 'group')
).order_by(*VLANGroup._meta.ordering) )
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
@ -653,8 +657,8 @@ class VLANGroupBulkImportView(generic.BulkImportView):
class VLANGroupBulkDeleteView(generic.BulkDeleteView): class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate( queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans') vlan_count=count_related(VLAN, 'group')
).order_by(*VLANGroup._meta.ordering) )
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable

View File

@ -1,10 +1,45 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.utils.encoding import force_str from django.utils.encoding import force_str
from rest_framework import exceptions
from rest_framework.metadata import SimpleMetadata from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from netbox.api import ContentTypeField from netbox.api import ContentTypeField
class ContentTypeMetadata(SimpleMetadata): class BulkOperationMetadata(SimpleMetadata):
def determine_actions(self, request, view):
"""
Replace the stock determine_actions() method to assess object permissions only
when viewing a specific object. This is necessary to support OPTIONS requests
with bulk update in place (see #5470).
"""
actions = {}
for method in {'PUT', 'POST'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions (if viewing a specific object)
if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
class ContentTypeMetadata(BulkOperationMetadata):
def get_field_info(self, field): def get_field_info(self, field):
field_info = super().get_field_info(field) field_info = super().get_field_info(field)

View File

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

View File

@ -7,8 +7,8 @@ ALLOWED_HOSTS = ['*']
DATABASE = { DATABASE = {
'NAME': 'netbox', 'NAME': 'netbox',
'USER': '', 'USER': 'netbox',
'PASSWORD': '', 'PASSWORD': 'netbox',
'HOST': 'localhost', 'HOST': 'localhost',
'PORT': '', 'PORT': '',
'CONN_MAX_AGE': 300, 'CONN_MAX_AGE': 300,

View File

@ -23,7 +23,7 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilterSet from tenancy.filters import TenantFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from tenancy.tables import TenantTable from tenancy.tables import TenantTable
from utilities.utils import get_subquery from utilities.utils import count_related
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable from virtualization.tables import ClusterTable, VirtualMachineDetailTable
@ -33,8 +33,8 @@ SEARCH_TYPES = OrderedDict((
# Circuits # Circuits
('provider', { ('provider', {
'queryset': Provider.objects.annotate( 'queryset': Provider.objects.annotate(
count_circuits=Count('circuits') count_circuits=count_related(Circuit, 'provider')
).order_by(*Provider._meta.ordering), ),
'filterset': ProviderFilterSet, 'filterset': ProviderFilterSet,
'table': ProviderTable, 'table': ProviderTable,
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
@ -61,17 +61,21 @@ SEARCH_TYPES = OrderedDict((
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}), }),
('rackgroup', { ('rackgroup', {
'queryset': RackGroup.objects.prefetch_related('site').annotate( 'queryset': RackGroup.objects.add_related_count(
rack_count=Count('racks') RackGroup.objects.all(),
).order_by(*RackGroup._meta.ordering), Rack,
'group',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': RackGroupFilterSet, 'filterset': RackGroupFilterSet,
'table': RackGroupTable, 'table': RackGroupTable,
'url': 'dcim:rackgroup_list', 'url': 'dcim:rackgroup_list',
}), }),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances') instance_count=count_related(Device, 'device_type')
).order_by(*DeviceType._meta.ordering), ),
'filterset': DeviceTypeFilterSet, 'filterset': DeviceTypeFilterSet,
'table': DeviceTypeTable, 'table': DeviceTypeTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
@ -86,8 +90,8 @@ SEARCH_TYPES = OrderedDict((
}), }),
('virtualchassis', { ('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate( 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=Count('members', distinct=True) member_count=count_related(Device, 'virtual_chassis')
).order_by(*VirtualChassis._meta.ordering), ),
'filterset': VirtualChassisFilterSet, 'filterset': VirtualChassisFilterSet,
'table': VirtualChassisTable, 'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list', 'url': 'dcim:virtualchassis_list',
@ -107,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
# Virtualization # Virtualization
('cluster', { ('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=get_subquery(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
), ),
'filterset': ClusterFilterSet, 'filterset': ClusterFilterSet,
'table': ClusterTable, 'table': ClusterTable,

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.10-beta2' VERSION = '2.10.3-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -211,9 +211,6 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
# TODO: Remove in v2.10 (see #5171)
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
# Caching # Caching
if 'caching' not in REDIS: if 'caching' not in REDIS:
@ -470,6 +467,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend', 'django_filters.rest_framework.DjangoFilterBackend',
), ),
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions', 'netbox.api.authentication.TokenPermissions',
@ -612,7 +610,7 @@ for plugin_name in PLUGINS:
# Validate user-provided configuration settings and assign defaults # Validate user-provided configuration settings and assign defaults
if plugin_name not in PLUGINS_CONFIG: if plugin_name not in PLUGINS_CONFIG:
PLUGINS_CONFIG[plugin_name] = {} PLUGINS_CONFIG[plugin_name] = {}
plugin_config.validate(PLUGINS_CONFIG[plugin_name]) plugin_config.validate(PLUGINS_CONFIG[plugin_name], VERSION)
# Add middleware # Add middleware
plugin_middleware = plugin_config.middleware plugin_middleware = plugin_config.middleware

View File

@ -212,6 +212,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission # Assign object permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'site__name': 'Site 1'}, constraints={'site__name': 'Site 1'},
actions=['view'] actions=['view']
) )
@ -239,6 +240,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission # Assign object permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'site__name': 'Site 1'}, constraints={'site__name': 'Site 1'},
actions=['view'] actions=['view']
) )
@ -266,6 +268,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission # Assign object permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'site__name': 'Site 1'}, constraints={'site__name': 'Site 1'},
actions=['add'] actions=['add']
) )
@ -295,6 +298,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission # Assign object permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'site__name': 'Site 1'}, constraints={'site__name': 'Site 1'},
actions=['change'] actions=['change']
) )
@ -330,6 +334,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission # Assign object permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'site__name': 'Site 1'}, constraints={'site__name': 'Site 1'},
actions=['delete'] actions=['delete']
) )

View File

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

View File

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

View File

@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
self.fields['plaintext'].required = True self.fields['plaintext'].required = True
def clean(self): def clean(self):
super().clean()
if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']: if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.") raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")
@ -143,31 +144,56 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
class SecretCSVForm(CustomFieldModelCSVForm): class SecretCSVForm(CustomFieldModelCSVForm):
assigned_object_type = CSVModelChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=SECRET_ASSIGNMENT_MODELS,
to_field_name='model',
help_text='Side A type'
)
role = CSVModelChoiceField( role = CSVModelChoiceField(
queryset=SecretRole.objects.all(), queryset=SecretRole.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned role' help_text='Assigned role'
) )
device = CSVModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned device'
)
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned VM'
)
plaintext = forms.CharField( plaintext = forms.CharField(
help_text='Plaintext secret data' help_text='Plaintext secret data'
) )
class Meta: class Meta:
model = Secret model = Secret
fields = Secret.csv_headers fields = ['role', 'name', 'plaintext', 'device', 'virtual_machine']
help_texts = { help_texts = {
'name': 'Name or username', 'name': 'Name or username',
} }
def clean(self):
super().clean()
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
# Validate device OR VM is assigned
if not device and not virtual_machine:
raise forms.ValidationError("Secret must be assigned to a device or a virtual machine")
if device and virtual_machine:
raise forms.ValidationError("Secret cannot be assigned to both a device and a virtual machine")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Set device/VM assignment
self.instance.assigned_object = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
s = super().save(*args, **kwargs) s = super().save(*args, **kwargs)
# Set plaintext on instance
s.plaintext = str(self.cleaned_data['plaintext']) s.plaintext = str(self.cleaned_data['plaintext'])
return s return s

View File

@ -30,15 +30,21 @@ class SecretRoleTable(BaseTable):
class SecretTable(BaseTable): class SecretTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
id = tables.Column( # Provides a link to the secret
linkify=True
)
assigned_object = tables.Column( assigned_object = tables.Column(
linkify=True, linkify=True,
verbose_name='Assigned object' verbose_name='Assigned object'
) )
role = tables.Column(
linkify=True
)
tags = TagColumn( tags = TagColumn(
url_name='secrets:secret_list' url_name='secrets:secret_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Secret model = Secret
fields = ('pk', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags') fields = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
default_columns = ('pk', 'assigned_object', 'role', 'name', 'last_updated') default_columns = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated')

View File

@ -103,10 +103,10 @@ class SecretTestCase(
device = Device.objects.get(name='Device 1') device = Device.objects.get(name='Device 1')
csv_data = ( csv_data = (
"assigned_object_type,assigned_object_id,role,name,plaintext", "device,role,name,plaintext",
f"device,{device.pk},Secret Role 1,Secret 4,abcdefghij", f"{device.name},Secret Role 1,Secret 4,abcdefghij",
f"device,{device.pk},Secret Role 1,Secret 5,abcdefghij", f"{device.name},Secret Role 1,Secret 5,abcdefghij",
f"device,{device.pk},Secret Role 1,Secret 6,abcdefghij", f"{device.name},Secret Role 1,Secret 6,abcdefghij",
) )
# Set the session_key cookie on the request # Set the session_key cookie on the request

View File

@ -2,12 +2,12 @@ import base64
import logging import logging
from django.contrib import messages from django.contrib import messages
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .models import SecretRole, Secret, SessionKey, UserKey from .models import SecretRole, Secret, SessionKey, UserKey
@ -27,7 +27,9 @@ def get_session_key(request):
# #
class SecretRoleListView(generic.ObjectListView): class SecretRoleListView(generic.ObjectListView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering) queryset = SecretRole.objects.annotate(
secret_count=count_related(Secret, 'role')
)
table = tables.SecretRoleTable table = tables.SecretRoleTable
@ -47,7 +49,9 @@ class SecretRoleBulkImportView(generic.BulkImportView):
class SecretRoleBulkDeleteView(generic.BulkDeleteView): class SecretRoleBulkDeleteView(generic.BulkDeleteView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering) queryset = SecretRole.objects.annotate(
secret_count=count_related(Secret, 'role')
)
table = tables.SecretRoleTable table = tables.SecretRoleTable
@ -94,13 +98,14 @@ class SecretEditView(generic.ObjectEditView):
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
secret = form.save(commit=False)
# We must have a session key in order to create a secret or update the plaintext of an existing secret # We must have a session key in order to set the plaintext of a Secret
if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None: if form.cleaned_data['plaintext'] and session_key is None:
logger.debug("Unable to proceed: No session key was provided with the request") logger.debug("Unable to proceed: No session key was provided with the request")
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
else: elif form.cleaned_data['plaintext']:
master_key = None master_key = None
try: try:
sk = SessionKey.objects.get(userkey__user=request.user) sk = SessionKey.objects.get(userkey__user=request.user)
@ -111,19 +116,18 @@ class SecretEditView(generic.ObjectEditView):
if master_key is not None: if master_key is not None:
logger.debug("Successfully resolved master key for encryption") logger.debug("Successfully resolved master key for encryption")
secret = form.save(commit=False) secret.plaintext = str(form.cleaned_data['plaintext'])
if form.cleaned_data['plaintext']:
secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save()
form.save_m2m()
msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified') secret.save()
logger.info(f"{msg} {secret} (PK: {secret.pk})") form.save_m2m()
msg = '{} <a href="{}">{}</a>'.format(msg, secret.get_absolute_url(), escape(secret))
messages.success(request, mark_safe(msg))
return redirect(self.get_return_url(request, secret)) msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
logger.info(f"{msg} {secret} (PK: {secret.pk})")
msg = f'{msg} <a href="{secret.get_absolute_url()}">{escape(secret)}</a>'
messages.success(request, mark_safe(msg))
return redirect(self.get_return_url(request, secret))
else: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")

View File

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

View File

@ -14,7 +14,7 @@
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li> <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{% url 'dcim:device_list' %}?site={{ object.site.slug }}">{{ object.site }}</a></li> <li><a href="{% url 'dcim:device_list' %}?site={{ object.site.slug }}">{{ object.site }}</a></li>
{% if object.parent_bay %} {% if object.parent_bay %}
<li><a href="{% url 'dcim:device' pk=object.parent_bay.object.pk %}">{{ object.parent_bay.device }}</a></li> <li><a href="{% url 'dcim:device' pk=object.parent_bay.device.pk %}">{{ object.parent_bay.device }}</a></li>
<li>{{ object.parent_bay }}</li> <li>{{ object.parent_bay }}</li>
{% endif %} {% endif %}
<li>{{ object }}</li> <li>{{ object }}</li>

View File

@ -9,7 +9,9 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li> <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{{ object.device.get_absolute_url }}">{{ object.device }}</a></li> <li><a href="{{ object.device.get_absolute_url }}">{{ object.device }}</a></li>
<li><a href="{% url object|viewname:"list" %}?device_id={{ object.device.pk }}">{{ object|meta:"verbose_name_plural"|bettertitle }}</a></li> {% if breadcrumb_url %}
<li><a href="{% url breadcrumb_url pk=object.device.pk %}">{{ object|meta:"verbose_name_plural"|bettertitle }}</a></li>
{% endif %}
<li>{{ object }}</li> <li>{{ object }}</li>
</ol> </ol>
</div> </div>

View File

@ -10,7 +10,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if perms.dcim.delete_cable %} {% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ device.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:cable_delete' pk=cable.pk %}?return_url={{ object.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -9,18 +9,18 @@
{% include 'responsive_table.html' %} {% include 'responsive_table.html' %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if table.rows %} {% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Rename <span class="mdi mdi-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</button> </button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger"> <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
<div class="pull-right"> <div class="pull-right">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs"> <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-xs">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Add {{ title }} Add {{ title }}
</a> </a>

View File

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

View File

@ -26,7 +26,8 @@
{% if page %} {% if page %}
<div style="white-space: nowrap; overflow-x: scroll;"> <div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %} {% for rack in page %}
<div style="display: inline-block; width: 266px"> <div style="display: inline-block; margin-right: 12px; width: 254px">
<div style="margin-left: 30px">
<div class="text-center"> <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.role %} {% if rack.role %}
@ -36,7 +37,7 @@
<br /><small class="text-muted">{{ rack.facility_id }}</small> <br /><small class="text-muted">{{ rack.facility_id }}</small>
{% endif %} {% endif %}
</div> </div>
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %} {% include 'dcim/inc/rack_elevation.html' with object=rack face=rack_face %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="text-center"> <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
@ -44,6 +45,7 @@
<small class="text-muted">({{ rack.facility_id }})</small> <small class="text-muted">({{ rack.facility_id }})</small>
{% endif %} {% endif %}
</div> </div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

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

View File

@ -1,3 +1,5 @@
{% load helpers %}
<div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}"> <div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
<strong> <strong>
<a href="{% url 'dcim:cable' pk=cable.pk %}"> <a href="{% url 'dcim:cable' pk=cable.pk %}">
@ -5,11 +7,13 @@
</a> </a>
</strong><br /> </strong><br />
{% if cable.type %} {% if cable.type %}
{{ cable.get_type_display|default:"" }} {{ cable.get_type_display|default:"" }}<br />
{% if cable.length %}
({{ cable.length }} {{ cable.get_length_unit_display }})
{% endif %}
<br />
{% endif %} {% endif %}
<span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span> {% if cable.length %}
({{ cable.length }} {{ cable.get_length_unit_display }})<br />
{% endif %}
<span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span><br />
{% for tag in cable.tags.all %}
{% tag tag 'dcim:cable_list' %}
{% endfor %}
</div> </div>

View File

@ -67,7 +67,7 @@
</td> </td>
<td> <td>
{% if field.STATIC_CHOICES %} {% if field.STATIC_CHOICES %}
<button type="button" class="btn btn-primary btn-xs pull-right" data-toggle="modal" data-target="#{{ name }}_choices"> <button type="button" class="btn btn-link btn-xs pull-right" data-toggle="modal" data-target="#{{ name }}_choices">
<i class="mdi mdi-help-circle"></i> <i class="mdi mdi-help-circle"></i>
</button> </button>
<div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog"> <div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog">

View File

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

View File

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

View File

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

View File

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

View File

@ -19,9 +19,23 @@ class UserSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ( fields = (
'id', 'url', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
'groups', 'date_joined', 'groups',
) )
extra_kwargs = {
'password': {'write_only': True}
}
def create(self, validated_data):
"""
Extract the password from validated data and set it separately to ensure proper hash generation.
"""
password = validated_data.pop('password')
user = super().create(validated_data)
user.set_password(password)
user.save()
return user
class GroupSerializer(ValidatedModelSerializer): class GroupSerializer(ValidatedModelSerializer):

View File

@ -21,15 +21,19 @@ class UserTest(APIViewTestCases.APIViewTestCase):
model = User model = User
view_namespace = 'users' view_namespace = 'users'
brief_fields = ['id', 'url', 'username'] brief_fields = ['id', 'url', 'username']
validation_excluded_fields = ['password']
create_data = [ create_data = [
{ {
'username': 'User_4', 'username': 'User_4',
'password': 'password4',
}, },
{ {
'username': 'User_5', 'username': 'User_5',
'password': 'password5',
}, },
{ {
'username': 'User_6', 'username': 'User_6',
'password': 'password6',
}, },
] ]
@ -94,7 +98,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
object_type = ContentType.objects.get(app_label='dcim', model='device') object_type = ContentType.objects.get(app_label='dcim', model='device')
for i in range(0, 3): for i in range(3):
objectpermission = ObjectPermission( objectpermission = ObjectPermission(
name=f'Permission {i+1}', name=f'Permission {i+1}',
actions=['view', 'add', 'change', 'delete'], actions=['view', 'add', 'change', 'delete'],

View File

@ -6,12 +6,11 @@ from io import StringIO
import django_filters import django_filters
from django import forms from django import forms
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Count from django.db.models import Count
from django.forms import BoundField from django.forms import BoundField
from django.urls import reverse from django.urls import reverse
from utilities.api import get_serializer_for_model
from utilities.choices import unpack_grouped_choices from utilities.choices import unpack_grouped_choices
from utilities.validators import EnhancedURLValidator from utilities.validators import EnhancedURLValidator
from . import widgets from . import widgets
@ -21,6 +20,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
__all__ = ( __all__ = (
'CommentField', 'CommentField',
'CSVChoiceField', 'CSVChoiceField',
'CSVContentTypeField',
'CSVDataField', 'CSVDataField',
'CSVModelChoiceField', 'CSVModelChoiceField',
'DynamicModelChoiceField', 'DynamicModelChoiceField',
@ -135,12 +135,32 @@ class CSVModelChoiceField(forms.ModelChoiceField):
def to_python(self, value): def to_python(self, value):
try: try:
return super().to_python(value) return super().to_python(value)
except MultipleObjectsReturned as e: except MultipleObjectsReturned:
raise forms.ValidationError( raise forms.ValidationError(
f'"{value}" is not a unique value for this field; multiple objects were found' f'"{value}" is not a unique value for this field; multiple objects were found'
) )
class CSVContentTypeField(CSVModelChoiceField):
"""
Reference a ContentType in the form <app>.<model>
"""
STATIC_CHOICES = True
def prepare_value(self, value):
return f'{value.app_label}.{value.model}'
def to_python(self, value):
try:
app_label, model = value.split('.')
except ValueError:
raise forms.ValidationError(f'Object type must be specified as "<app>.<model>"')
try:
return self.queryset.get(app_label=app_label, model=model)
except ObjectDoesNotExist:
raise forms.ValidationError(f'Invalid object type')
class ExpandableNameField(forms.CharField): class ExpandableNameField(forms.CharField):
""" """
A field which allows for numeric range expansion A field which allows for numeric range expansion

View File

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

View File

@ -93,6 +93,7 @@ class APIViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': instance1.pk}, constraints={'pk': instance1.pk},
actions=['view'] actions=['view']
) )
@ -108,6 +109,15 @@ class APIViewTestCases:
url = self._get_detail_url(instance2) url = self._get_detail_url(instance2)
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND) self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_object(self):
"""
Make an OPTIONS request for a single object.
"""
url = self._get_detail_url(self._get_queryset().first())
response = self.client.options(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ListObjectsViewTestCase(APITestCase): class ListObjectsViewTestCase(APITestCase):
brief_fields = [] brief_fields = []
@ -160,6 +170,7 @@ class APIViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk__in': [instance1.pk, instance2.pk]}, constraints={'pk__in': [instance1.pk, instance2.pk]},
actions=['view'] actions=['view']
) )
@ -172,8 +183,17 @@ class APIViewTestCases:
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2) self.assertEqual(len(response.data['results']), 2)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_options_objects(self):
"""
Make an OPTIONS request for a list endpoint.
"""
response = self.client.options(self._get_list_url(), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class CreateObjectViewTestCase(APITestCase): class CreateObjectViewTestCase(APITestCase):
create_data = [] create_data = []
validation_excluded_fields = []
def test_create_object_without_permission(self): def test_create_object_without_permission(self):
""" """
@ -192,6 +212,7 @@ class APIViewTestCases:
""" """
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'] actions=['add']
) )
obj_perm.save() obj_perm.save()
@ -205,6 +226,7 @@ class APIViewTestCases:
self.assertInstanceEqual( self.assertInstanceEqual(
self._get_queryset().get(pk=response.data['id']), self._get_queryset().get(pk=response.data['id']),
self.create_data[0], self.create_data[0],
exclude=self.validation_excluded_fields,
api=True api=True
) )
@ -214,6 +236,7 @@ class APIViewTestCases:
""" """
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'] actions=['add']
) )
obj_perm.save() obj_perm.save()
@ -227,17 +250,20 @@ class APIViewTestCases:
self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data)) self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
for i, obj in enumerate(response.data): for i, obj in enumerate(response.data):
for field in self.create_data[i]: for field in self.create_data[i]:
self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response") if field not in self.validation_excluded_fields:
self.assertIn(field, obj, f"Bulk create field '{field}' missing from object {i} in response")
for i, obj in enumerate(response.data): for i, obj in enumerate(response.data):
self.assertInstanceEqual( self.assertInstanceEqual(
self._get_queryset().get(pk=obj['id']), self._get_queryset().get(pk=obj['id']),
self.create_data[i], self.create_data[i],
exclude=self.validation_excluded_fields,
api=True api=True
) )
class UpdateObjectViewTestCase(APITestCase): class UpdateObjectViewTestCase(APITestCase):
update_data = {} update_data = {}
bulk_update_data = None bulk_update_data = None
validation_excluded_fields = []
def test_update_object_without_permission(self): def test_update_object_without_permission(self):
""" """
@ -261,6 +287,7 @@ class APIViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['change'] actions=['change']
) )
obj_perm.save() obj_perm.save()
@ -270,7 +297,12 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header) response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db() instance.refresh_from_db()
self.assertInstanceEqual(instance, update_data, api=True) self.assertInstanceEqual(
instance,
update_data,
exclude=self.validation_excluded_fields,
api=True
)
def test_bulk_update_objects(self): def test_bulk_update_objects(self):
""" """
@ -281,6 +313,7 @@ class APIViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['change'] actions=['change']
) )
obj_perm.save() obj_perm.save()
@ -323,6 +356,7 @@ class APIViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['delete'] actions=['delete']
) )
obj_perm.save() obj_perm.save()
@ -339,6 +373,7 @@ class APIViewTestCases:
""" """
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['delete'] actions=['delete']
) )
obj_perm.save() obj_perm.save()

View File

@ -100,7 +100,7 @@ class TestCase(_TestCase):
""" """
for name in names: for name in names:
ct, action = resolve_permission_ct(name) ct, action = resolve_permission_ct(name)
obj_perm = ObjectPermission(actions=[action]) obj_perm = ObjectPermission(name=name, actions=[action])
obj_perm.save() obj_perm.save()
obj_perm.users.add(self.user) obj_perm.users.add(self.user)
obj_perm.object_types.add(ct) obj_perm.object_types.add(ct)
@ -126,20 +126,25 @@ class TestCase(_TestCase):
err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}" err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
self.assertEqual(response.status_code, expected_status, err_message) self.assertEqual(response.status_code, expected_status, err_message)
def assertInstanceEqual(self, instance, data, api=False): def assertInstanceEqual(self, instance, data, exclude=None, api=False):
""" """
Compare a model instance to a dictionary, checking that its attribute values match those specified Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary. in the dictionary.
:instance: Python object instance :param instance: Python object instance
:data: Dictionary of test data used to define the instance :param data: Dictionary of test data used to define the instance
:api: Set to True is the data is a JSON representation of the instance :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
:param api: Set to True is the data is a JSON representation of the instance
""" """
model_dict = self.model_to_dict(instance, fields=data.keys(), api=api) if exclude is None:
exclude = []
# Omit any dictionary keys which are not instance attributes fields = [k for k in data.keys() if k not in exclude]
model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = { relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k) k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
} }
self.assertDictEqual(model_dict, relevant_data) self.assertDictEqual(model_dict, relevant_data)
@ -240,6 +245,7 @@ class ViewTestCases:
# Add model-level permission # Add model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['view'] actions=['view']
) )
obj_perm.save() obj_perm.save()
@ -255,6 +261,7 @@ class ViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': instance1.pk}, constraints={'pk': instance1.pk},
actions=['view'] actions=['view']
) )
@ -307,6 +314,7 @@ class ViewTestCases:
# Assign unconstrained permission # Assign unconstrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'] actions=['add']
) )
obj_perm.save() obj_perm.save()
@ -331,6 +339,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all constraints={'pk': 0}, # Dummy permission to deny all
actions=['add'] actions=['add']
) )
@ -391,6 +400,7 @@ class ViewTestCases:
# Assign model-level permission # Assign model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['change'] actions=['change']
) )
obj_perm.save() obj_perm.save()
@ -414,6 +424,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': instance1.pk}, constraints={'pk': instance1.pk},
actions=['change'] actions=['change']
) )
@ -467,6 +478,7 @@ class ViewTestCases:
# Assign model-level permission # Assign model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['delete'] actions=['delete']
) )
obj_perm.save() obj_perm.save()
@ -491,6 +503,7 @@ class ViewTestCases:
# Assign object-level permission # Assign object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': instance1.pk}, constraints={'pk': instance1.pk},
actions=['delete'] actions=['delete']
) )
@ -544,6 +557,7 @@ class ViewTestCases:
# Add model-level permission # Add model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['view'] actions=['view']
) )
obj_perm.save() obj_perm.save()
@ -565,6 +579,7 @@ class ViewTestCases:
# Add object-level permission # Add object-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': instance1.pk}, constraints={'pk': instance1.pk},
actions=['view'] actions=['view']
) )
@ -614,6 +629,7 @@ class ViewTestCases:
# Assign non-constrained permission # Assign non-constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'], actions=['add'],
) )
obj_perm.save() obj_perm.save()
@ -637,6 +653,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'], actions=['add'],
constraints={'pk': 0} # Dummy constraint to deny all constraints={'pk': 0} # Dummy constraint to deny all
) )
@ -692,6 +709,7 @@ class ViewTestCases:
# Assign model-level permission # Assign model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['add'] actions=['add']
) )
obj_perm.save() obj_perm.save()
@ -714,6 +732,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all constraints={'pk': 0}, # Dummy permission to deny all
actions=['add'] actions=['add']
) )
@ -770,6 +789,7 @@ class ViewTestCases:
# Assign model-level permission # Assign model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['change'] actions=['change']
) )
obj_perm.save() obj_perm.save()
@ -799,6 +819,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={attr_name: value}, constraints={attr_name: value},
actions=['change'] actions=['change']
) )
@ -851,6 +872,7 @@ class ViewTestCases:
# Assign unconstrained permission # Assign unconstrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['delete'] actions=['delete']
) )
obj_perm.save() obj_perm.save()
@ -873,6 +895,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'pk': 0}, # Dummy permission to deny all constraints={'pk': 0}, # Dummy permission to deny all
actions=['delete'] actions=['delete']
) )
@ -930,6 +953,7 @@ class ViewTestCases:
# Assign model-level permission # Assign model-level permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
actions=['change'] actions=['change']
) )
obj_perm.save() obj_perm.save()
@ -953,6 +977,7 @@ class ViewTestCases:
# Assign constrained permission # Assign constrained permission
obj_perm = ObjectPermission( obj_perm = ObjectPermission(
name='Test permission',
constraints={'name__regex': '[^X]$'}, constraints={'name__regex': '[^X]$'},
actions=['change'] actions=['change']
) )

View File

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

View File

@ -116,3 +116,16 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'untagged_vlan', 'tagged_vlans', 'tags',
] ]
def validate(self, data):
# Validate many-to-many VLAN assignments
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [virtual_machine.site, None]:
raise serializers.ValidationError({
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
f"machine, or it must be global."
})
return super().validate(data)

View File

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

View File

@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
@ -543,10 +543,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
# VM interfaces # VM interfaces
# #
class VMInterfaceForm(BootstrapMixin, forms.ModelForm): class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
label='Untagged VLAN',
display_field='display_name', display_field='display_name',
brief_mode=False, brief_mode=False,
query_params={ query_params={
@ -556,6 +557,7 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
label='Tagged VLANs',
display_field='display_name', display_field='display_name',
brief_mode=False, brief_mode=False,
query_params={ query_params={
@ -597,24 +599,8 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
def clean(self):
super().clean()
# Validate VLAN assignments class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
virtual_machine = DynamicModelChoiceField( virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all() queryset=VirtualMachine.objects.all()
) )

View File

@ -5,7 +5,6 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from dcim.choices import InterfaceModeChoices
from dcim.models import BaseInterface, Device from dcim.models import BaseInterface, Device
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
@ -309,10 +308,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention. # of the uniqueness constraint without manual intervention.
if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter(
name=self.name, tenant__isnull=True name=self.name, cluster=self.cluster, tenant__isnull=True
): ):
raise ValidationError({ raise ValidationError({
'name': 'A virtual machine with this name already exists.' 'name': 'A virtual machine with this name already exists in the assigned cluster.'
}) })
super().validate_unique(exclude) super().validate_unique(exclude)
@ -449,8 +448,8 @@ class VMInterface(BaseInterface):
# Validate untagged VLAN # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({ raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
"virtual machine, or it must be global".format(self.untagged_vlan) f"interface's parent virtual machine, or it must be global"
}) })
def to_objectchange(self, action): def to_objectchange(self, action):

View File

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

View File

@ -1,6 +1,6 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch from django.db.models import Prefetch
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from secrets.models import Secret from secrets.models import Secret
from utilities.utils import get_subquery from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -21,7 +21,9 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
# #
class ClusterTypeListView(generic.ObjectListView): class ClusterTypeListView(generic.ObjectListView):
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering) queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
)
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -41,7 +43,9 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
class ClusterTypeBulkDeleteView(generic.BulkDeleteView): class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering) queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
)
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
@ -50,7 +54,9 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
# #
class ClusterGroupListView(generic.ObjectListView): class ClusterGroupListView(generic.ObjectListView):
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering) queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
)
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@ -70,7 +76,9 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
class ClusterGroupBulkDeleteView(generic.BulkDeleteView): class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering) queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
)
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@ -81,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
class ClusterListView(generic.ObjectListView): class ClusterListView(generic.ObjectListView):
permission_required = 'virtualization.view_cluster' permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.annotate( queryset = Cluster.objects.annotate(
device_count=get_subquery(Device, 'cluster'), device_count=count_related(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster') vm_count=count_related(VirtualMachine, 'cluster')
) )
table = tables.ClusterTable table = tables.ClusterTable
filterset = filters.ClusterFilterSet filterset = filters.ClusterFilterSet

View File

@ -1,60 +0,0 @@
#!/bin/bash
# Exit code starts at 0 but is modified if any checks fail
EXIT=0
# Output a line prefixed with a timestamp
info()
{
echo "$(date +'%F %T') |"
}
# Track number of seconds required to run script
START=$(date +%s)
echo "$(info) starting build checks."
# Syntax check all python source files
SYNTAX=$(find . -name "*.py" -type f -exec python -m py_compile {} \; 2>&1)
if [[ ! -z $SYNTAX ]]; then
echo -e "$SYNTAX"
echo -e "\n$(info) detected one or more syntax errors, failing build."
EXIT=1
fi
# Check all python source files for PEP 8 compliance, but explicitly
# ignore:
# - W504: line break after binary operator
# - E501: line greater than 80 characters in length
pycodestyle \
--ignore=W504,E501 \
netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more PEP 8 errors detected, failing build."
EXIT=$RC
fi
# Point to the testing configuration file for use in CI
ln -s configuration.testing.py netbox/netbox/configuration.py
# Run NetBox tests
coverage run --source="netbox/" netbox/manage.py test netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more tests failed, failing build."
EXIT=$RC
fi
# Show code coverage report
coverage report --skip-covered --omit *migrations*
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) failed to generate code coverage report."
EXIT=$RC
fi
# Show build duration
END=$(date +%s)
echo "$(info) exiting with code $EXIT after $(($END - $START)) seconds."
exit $EXIT