mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 18:08:38 -06:00
Merge remote-tracking branch 'True-NetBox/develop' into develop-2.10
This commit is contained in:
commit
8de4cbbf50
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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.
|
||||
|
||||
This form is only for reproducible bugs. If you need assistance with
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
This form is only for reporting reproducible bugs. If you need assistance
|
||||
with NetBox installation, or if you have a general question, please start a
|
||||
discussion instead: https://github.com/netbox-community/netbox/discussions
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
|
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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.
|
||||
|
||||
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
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
If you have a general idea or question, please start a discussion instead:
|
||||
https://github.com/netbox-community/netbox/discussions
|
||||
|
||||
NOTE: Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which significantly extend NetBox's feature scope.
|
||||
|
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal 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*
|
20
.travis.yml
20
.travis.yml
@ -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
|
@ -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
|
||||
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
|
||||
|
||||
We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
|
||||
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
|
||||
for general discussion, however we're encouraging people to use GitHub
|
||||
discussions where possible, as it's much easier for newcomers to review past
|
||||
discussions.
|
||||
|
||||
### 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
|
||||
|
||||
@ -171,7 +187,7 @@ overlooked.
|
||||
|
||||
* 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** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
|
||||
|
||||
|
29
README.md
29
README.md
@ -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.
|
||||
|
||||
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
|
||||
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
|
||||
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
|
||||
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
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/)!
|
||||
|
||||
### Build Status
|
||||
|
||||
| | status |
|
||||
|-------------|------------|
|
||||
| **master** | [](https://travis-ci.com/netbox-community/netbox/) |
|
||||
| **develop** | [](https://travis-ci.com/netbox-community/netbox/) |
|
||||
| **master** |  |
|
||||
| **develop** |  |
|
||||
|
||||
### Screenshots
|
||||
|
||||
@ -36,19 +36,22 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
|
||||
|
||||
## Installation
|
||||
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
|
||||
and run `upgrade.sh`.
|
||||
Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
|
||||
instructions on installing NetBox. To upgrade NetBox, please download the
|
||||
[latest release](https://github.com/netbox-community/netbox/releases) and
|
||||
run `upgrade.sh`.
|
||||
|
||||
## Providing Feedback
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
|
||||
The best platform for general feedback, assistance, and other discussion is our
|
||||
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
|
||||
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
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
## 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.
|
||||
|
@ -4,10 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
|
||||
|
||||
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
|
||||
|
||||
Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
|
||||
|
||||
!!! warning
|
||||
Support for Django's native templating logic will be removed in NetBox v2.10.
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
|
||||
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
|
||||
|
||||
|
@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
Default: None
|
||||
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
|
||||
|
||||
```python
|
||||
BASE_PATH = 'netbox/'
|
||||
@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
|
||||
|
||||
## NAPALM_ARGS
|
||||
|
||||
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
|
||||
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
|
||||
|
||||
```python
|
||||
NAPALM_ARGS = {
|
||||
@ -460,7 +460,7 @@ NetBox can be configured to support remote user authentication by inferring 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`.)
|
||||
|
||||
---
|
||||
|
||||
|
@ -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:
|
||||
|
||||
* [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.
|
||||
|
||||
## Governance
|
||||
|
@ -1,9 +1,9 @@
|
||||
# HTTP Server Setup
|
||||
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 20.04 instructions are provided here, these tasks 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
|
||||
|
||||
|
@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
|
||||
|
||||
### General Server Configuration
|
||||
|
||||
|
@ -63,11 +63,15 @@ setup(
|
||||
install_requires=[],
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
|
||||
|
||||
!!! note
|
||||
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
|
||||
|
||||
### Define a PluginConfig
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
|
||||
|
||||
#### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
|
||||
|
||||
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
|
||||
The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
@ -1,21 +1,46 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10-beta2 (FUTURE)
|
||||
## v2.10.2 (2020-12-21)
|
||||
|
||||
### 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
|
||||
|
||||
* [#5176](https://github.com/netbox-community/netbox/issues/5176) - Enforce content type restrictions when creating objects via the REST API
|
||||
* [#5358](https://github.com/netbox-community/netbox/issues/5358) - Fix user table configuration for VM interfaces
|
||||
* [#5374](https://github.com/netbox-community/netbox/issues/5374) - Fix exception thrown when tracing mid-point
|
||||
* [#5376](https://github.com/netbox-community/netbox/issues/5376) - Correct invalid custom field filter logic values
|
||||
* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
|
||||
* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
|
||||
* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
|
||||
* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
|
||||
* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
|
||||
* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
|
||||
* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
|
||||
* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
|
||||
* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
|
||||
* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
|
||||
* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
|
||||
* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
|
||||
|
||||
---
|
||||
|
||||
## v2.10-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.
|
||||
|
||||
@ -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
|
||||
* [#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/`)
|
||||
* [#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
|
||||
|
||||
* [#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
|
||||
* [#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
|
||||
* [#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
|
||||
|
@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
|
||||
|
||||
#### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
|
||||
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
|
||||
Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
@ -1,5 +1,26 @@
|
||||
# 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)
|
||||
|
||||
### Enhancements
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
## What is a REST API?
|
||||
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
|
||||
* `GET`: Retrieve an object or list of objects
|
||||
* `POST`: Create an object
|
||||
|
@ -1,4 +1,4 @@
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Prefetch
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from circuits import filters
|
||||
@ -6,6 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from dcim.api.views import PathEndpointMixin
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -23,8 +24,8 @@ class CircuitsRootView(APIRootView):
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=Count('circuits')
|
||||
).order_by(*Provider._meta.ordering)
|
||||
circuit_count=count_related(Circuit, 'provider')
|
||||
)
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
|
||||
@ -35,8 +36,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=Count('circuits')
|
||||
).order_by(*CircuitType._meta.ordering)
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilterSet
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@ -17,7 +17,9 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
#
|
||||
|
||||
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_form = forms.ProviderFilterForm
|
||||
table = tables.ProviderTable
|
||||
@ -64,14 +66,18 @@ class ProviderBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
table = tables.ProviderTable
|
||||
form = forms.ProviderBulkEditForm
|
||||
|
||||
|
||||
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
|
||||
table = tables.ProviderTable
|
||||
|
||||
@ -81,7 +87,9 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -101,7 +109,9 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -129,7 +139,7 @@ class CircuitView(generic.ObjectView):
|
||||
).filter(
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).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')
|
||||
|
||||
# Z-side termination
|
||||
@ -138,7 +148,7 @@ class CircuitView(generic.ObjectView):
|
||||
).filter(
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).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')
|
||||
|
||||
return {
|
||||
|
@ -584,22 +584,15 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
|
||||
'count_ipaddresses',
|
||||
]
|
||||
|
||||
# TODO: This validation should be handled by Interface.clean()
|
||||
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')
|
||||
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', []):
|
||||
if vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||
"be global.".format(vlan)
|
||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
|
||||
f"it must be global."
|
||||
})
|
||||
|
||||
return super().validate(data)
|
||||
@ -773,11 +766,10 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
@swagger_serializer_method(serializer_or_field=serializers.ListField)
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for node in obj.path:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
ct = ContentType.objects.get_for_id(ct_id)
|
||||
# TODO: Return the object URL
|
||||
ret.append(f'{ct.app_label}.{ct.model}:{object_id}')
|
||||
for node in obj.get_path():
|
||||
serializer = get_serializer_for_model(node, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(node, context=context).data)
|
||||
return ret
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@ import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
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.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
@ -30,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@ -119,13 +119,13 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.prefetch_related(
|
||||
'region', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'site'),
|
||||
rack_count=get_subquery(Rack, 'site'),
|
||||
prefix_count=get_subquery(Prefix, 'site'),
|
||||
vlan_count=get_subquery(VLAN, 'site'),
|
||||
circuit_count=get_subquery(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
|
||||
).order_by(*Site._meta.ordering)
|
||||
device_count=count_related(Device, 'site'),
|
||||
rack_count=count_related(Rack, 'site'),
|
||||
prefix_count=count_related(Prefix, 'site'),
|
||||
vlan_count=count_related(VLAN, 'site'),
|
||||
circuit_count=count_related(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
|
||||
)
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilterSet
|
||||
|
||||
@ -152,8 +152,8 @@ class RackGroupViewSet(ModelViewSet):
|
||||
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=Count('racks')
|
||||
).order_by(*RackRole._meta.ordering)
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilterSet
|
||||
|
||||
@ -166,9 +166,9 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group__site', 'role', 'tenant', 'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack'),
|
||||
powerfeed_count=get_subquery(PowerFeed, 'rack')
|
||||
).order_by(*Rack._meta.ordering)
|
||||
device_count=count_related(Device, 'rack'),
|
||||
powerfeed_count=count_related(PowerFeed, 'rack')
|
||||
)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilterSet
|
||||
|
||||
@ -240,10 +240,10 @@ class RackReservationViewSet(ModelViewSet):
|
||||
|
||||
class ManufacturerViewSet(ModelViewSet):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
).order_by(*Manufacturer._meta.ordering)
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filters.ManufacturerFilterSet
|
||||
|
||||
@ -254,8 +254,8 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
|
||||
device_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
device_count=count_related(Device, 'device_type')
|
||||
)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
|
||||
@ -318,9 +318,9 @@ class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'role')
|
||||
).order_by(*DeviceRole._meta.ordering)
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilterSet
|
||||
|
||||
@ -331,9 +331,9 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
|
||||
).order_by(*Platform._meta.ordering)
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filters.PlatformFilterSet
|
||||
|
||||
@ -342,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@ -596,8 +596,8 @@ class CableViewSet(ModelViewSet):
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering)
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
|
||||
@ -610,8 +610,8 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilterSet
|
||||
|
||||
|
@ -887,6 +887,8 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_LSH = 'lsh'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
TYPE_SN = 'sn'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
@ -914,6 +916,8 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_SC, 'SC'),
|
||||
(TYPE_SC_APC, 'SC/APC'),
|
||||
(TYPE_ST, 'ST'),
|
||||
(TYPE_CS, 'CS'),
|
||||
(TYPE_SN, 'SN'),
|
||||
(TYPE_SPLICE, 'Splice'),
|
||||
)
|
||||
)
|
||||
|
@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
choices=RackStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=RackTypeChoices
|
||||
)
|
||||
width = django_filters.MultipleChoiceFilter(
|
||||
choices=RackWidthChoices
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
|
||||
'outer_width', 'outer_depth', 'outer_unit',
|
||||
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
label='User (ID)',
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user',
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label='User (name)',
|
||||
|
@ -21,10 +21,11 @@ from ipam.models import IPAddress, VLAN
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK,
|
||||
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCommonForm:
|
||||
class InterfaceCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
tagged_vlans = self.cleaned_data['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
|
||||
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]
|
||||
|
||||
if invalid_vlans:
|
||||
raise forms.ValidationError({
|
||||
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or they must be global".format(', '.join(invalid_vlans))
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=RackTypeChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
width = forms.MultipleChoiceField(
|
||||
choices=RackWidthChoices,
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
)
|
||||
role = DynamicModelMultipleChoiceField(
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
label='Rack group',
|
||||
null_option='None'
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
display_field='username',
|
||||
label='User',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@ -922,7 +941,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'front_image', 'rear_image', 'comments', 'tags',
|
||||
]
|
||||
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)
|
||||
|
||||
|
||||
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@ -2832,7 +2858,7 @@ class InterfaceBulkCreateForm(
|
||||
|
||||
class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
|
||||
'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode'
|
||||
]),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
@ -2848,6 +2874,15 @@ class InterfaceBulkEditForm(
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
label='Management only'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@ -3758,10 +3793,9 @@ class CableCSVForm(CSVModelForm):
|
||||
to_field_name='name',
|
||||
help_text='Side A device'
|
||||
)
|
||||
side_a_type = CSVModelChoiceField(
|
||||
side_a_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side A type'
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
@ -3774,10 +3808,9 @@ class CableCSVForm(CSVModelForm):
|
||||
to_field_name='name',
|
||||
help_text='Side B device'
|
||||
)
|
||||
side_b_type = CSVModelChoiceField(
|
||||
side_b_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side B type'
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
@ -3811,58 +3844,36 @@ class CableCSVForm(CSVModelForm):
|
||||
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
||||
}
|
||||
|
||||
# TODO: Merge the clean() methods for either end
|
||||
def clean_side_a_name(self):
|
||||
def _clean_side(self, side):
|
||||
"""
|
||||
Derive a Cable's A/B termination objects.
|
||||
|
||||
device = self.cleaned_data.get('side_a_device')
|
||||
content_type = self.cleaned_data.get('side_a_type')
|
||||
name = self.cleaned_data.get('side_a_name')
|
||||
:param side: 'a' or 'b'
|
||||
"""
|
||||
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:
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
termination_object = model.objects.get(
|
||||
device=device,
|
||||
name=name
|
||||
)
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(
|
||||
"Side A: {} {} is already connected".format(device, termination_object)
|
||||
)
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
"A side termination not found: {} {}".format(device, name)
|
||||
)
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
self.instance.termination_a = termination_object
|
||||
setattr(self.instance, f'termination_{side}', termination_object)
|
||||
return termination_object
|
||||
|
||||
def clean_side_a_name(self):
|
||||
return self._clean_side('a')
|
||||
|
||||
def clean_side_b_name(self):
|
||||
|
||||
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
|
||||
return self._clean_side('b')
|
||||
|
||||
def clean_length_unit(self):
|
||||
# Avoid trying to save as NULL
|
||||
|
@ -30,6 +30,13 @@ class Command(BaseCommand):
|
||||
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):
|
||||
|
||||
# If --force was passed, first delete all existing CablePaths
|
||||
@ -67,15 +74,15 @@ class Command(BaseCommand):
|
||||
origins = origins.filter(_path__isnull=True)
|
||||
origins_count = 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
|
||||
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
|
||||
for i, obj in enumerate(origins, start=1):
|
||||
create_cablepath(obj)
|
||||
# TODO: Come up with a better progress indicator
|
||||
if not i % 1000:
|
||||
self.stdout.write(f' {i}')
|
||||
self.stdout.write(self.style.SUCCESS(f' Retraced {i} {model._meta.verbose_name_plural}'))
|
||||
if not i % 100:
|
||||
self.draw_progress_bar(i * 100 / origins_count)
|
||||
self.draw_progress_bar(100)
|
||||
self.stdout.write(self.style.SUCCESS(f'\n Retraced {i} {model._meta.verbose_name_plural}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Finished.'))
|
||||
|
@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
|
||||
return instance
|
||||
|
||||
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):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
@ -411,21 +412,27 @@ class CablePath(models.Model):
|
||||
position_stack.append(peer_termination.rear_port_position)
|
||||
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):
|
||||
path.append(object_to_path_node(peer_termination))
|
||||
|
||||
# Determine the peer FrontPort's position
|
||||
if peer_termination.positions == 1:
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1)
|
||||
path.append(object_to_path_node(node))
|
||||
position = 1
|
||||
elif position_stack:
|
||||
position = position_stack.pop()
|
||||
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
|
||||
path.append(object_to_path_node(node))
|
||||
else:
|
||||
# No position indicated: path has split, so we stop at the RearPort
|
||||
is_split = True
|
||||
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
|
||||
else:
|
||||
destination = peer_termination
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
from cacheops import invalidate_obj
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.db import transaction
|
||||
@ -30,6 +31,7 @@ def rebuild_paths(obj):
|
||||
|
||||
with transaction.atomic():
|
||||
for cp in cable_paths:
|
||||
invalidate_obj(cp.origin)
|
||||
cp.delete()
|
||||
create_cablepath(cp.origin)
|
||||
|
||||
|
@ -447,7 +447,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else ''
|
||||
'class': lambda record: record.cable.get_status_class() if record.cable else '',
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
||||
|
||||
|
@ -45,9 +45,15 @@ INTERFACE_IPADDRESSES = """
|
||||
"""
|
||||
|
||||
INTERFACE_TAGGED_VLANS = """
|
||||
{% for vlan in record.tagged_vlans.unrestricted %}
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||
{% endfor %}
|
||||
{% if record.mode == 'tagged' %}
|
||||
{% for vlan in record.tagged_vlans.all %}
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
|
||||
{% endfor %}
|
||||
{% elif record.mode == 'tagged-all' %}
|
||||
All
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
MPTT_LINK = """
|
||||
|
@ -796,6 +796,30 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
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):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
|
@ -329,7 +329,7 @@ class RackTestCase(TestCase):
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
|
||||
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
@ -351,13 +351,11 @@ class RackTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': RackTypeChoices.TYPE_2POST}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_width(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'width': RackWidthChoices.WIDTH_19IN}
|
||||
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_u_height(self):
|
||||
@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
|
||||
users = User.objects.all()[:2]
|
||||
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
# TODO: Filtering by username is broken
|
||||
# params = {'user': [users[0].username, users[1].username]}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'user': [users[0].username, users[1].username]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
|
@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'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):
|
||||
model = Manufacturer
|
||||
@ -1668,9 +1676,9 @@ class CableTestCase(
|
||||
|
||||
cls.csv_data = (
|
||||
"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,interface,Interface 2,Device 4,interface,Interface 2",
|
||||
"Device 3,interface,Interface 3,Device 4,interface,Interface 3",
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
@ -4,7 +4,7 @@ from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
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.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.html import escape
|
||||
@ -20,7 +20,7 @@ from secrets.models import Secret
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import csv_format, get_subquery
|
||||
from utilities.utils import csv_format, count_related
|
||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
@ -253,7 +253,9 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -273,7 +275,9 @@ class RackRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -282,9 +286,11 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class RackListView(generic.ObjectListView):
|
||||
queryset = Rack.objects.annotate(
|
||||
device_count=Count('devices')
|
||||
).order_by(*Rack._meta.ordering)
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'devices__device_type'
|
||||
).annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filters.RackFilterSet
|
||||
filterset_form = forms.RackFilterForm
|
||||
table = tables.RackDetailTable
|
||||
@ -467,9 +473,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
platform_count=count_related(Platform, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@ -491,8 +497,8 @@ class ManufacturerBulkImportView(generic.BulkImportView):
|
||||
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=Count('device_types')
|
||||
).order_by(*Manufacturer._meta.ordering)
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@ -501,9 +507,9 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceTypeListView(generic.ObjectListView):
|
||||
queryset = DeviceType.objects.annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
filterset_form = forms.DeviceTypeFilterForm
|
||||
table = tables.DeviceTypeTable
|
||||
@ -609,8 +615,8 @@ class DeviceTypeImportView(generic.ObjectImportView):
|
||||
|
||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
form = forms.DeviceTypeBulkEditForm
|
||||
@ -618,8 +624,8 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
filterset = filters.DeviceTypeFilterSet
|
||||
table = tables.DeviceTypeTable
|
||||
|
||||
@ -910,8 +916,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceRoleListView(generic.ObjectListView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
vm_count=get_subquery(VirtualMachine, 'role')
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
@ -942,8 +948,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PlatformListView(generic.ObjectListView):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
vm_count=get_subquery(VirtualMachine, 'platform')
|
||||
device_count=count_related(Device, 'platform'),
|
||||
vm_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
table = tables.PlatformTable
|
||||
|
||||
@ -1331,6 +1337,11 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
class ConsolePortView(generic.ObjectView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_consoleports'
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
@ -1391,6 +1402,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
class ConsoleServerPortView(generic.ObjectView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_consoleserverports'
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
@ -1451,6 +1467,11 @@ class PowerPortListView(generic.ObjectListView):
|
||||
class PowerPortView(generic.ObjectView):
|
||||
queryset = PowerPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_powerports'
|
||||
}
|
||||
|
||||
|
||||
class PowerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPort.objects.all()
|
||||
@ -1511,6 +1532,11 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
class PowerOutletView(generic.ObjectView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_poweroutlets'
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
@ -1595,6 +1621,7 @@ class InterfaceView(generic.ObjectView):
|
||||
return {
|
||||
'ipaddress_table': ipaddress_table,
|
||||
'vlan_table': vlan_table,
|
||||
'breadcrumb_url': 'dcim:device_interfaces'
|
||||
}
|
||||
|
||||
|
||||
@ -1657,6 +1684,11 @@ class FrontPortListView(generic.ObjectListView):
|
||||
class FrontPortView(generic.ObjectView):
|
||||
queryset = FrontPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_frontports'
|
||||
}
|
||||
|
||||
|
||||
class FrontPortCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPort.objects.all()
|
||||
@ -1717,6 +1749,11 @@ class RearPortListView(generic.ObjectListView):
|
||||
class RearPortView(generic.ObjectView):
|
||||
queryset = RearPort.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_rearports'
|
||||
}
|
||||
|
||||
|
||||
class RearPortCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPort.objects.all()
|
||||
@ -1777,6 +1814,11 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
class DeviceBayView(generic.ObjectView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_devicebays'
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
@ -1898,6 +1940,11 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
class InventoryItemView(generic.ObjectView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'breadcrumb_url': 'dcim:device_inventory'
|
||||
}
|
||||
|
||||
|
||||
class InventoryItemEditView(generic.ObjectEditView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
@ -2290,9 +2337,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
#
|
||||
|
||||
class VirtualChassisListView(generic.ObjectListView):
|
||||
queryset = VirtualChassis.objects.annotate(
|
||||
member_count=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering)
|
||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
)
|
||||
table = tables.VirtualChassisTable
|
||||
filterset = filters.VirtualChassisFilterSet
|
||||
filterset_form = forms.VirtualChassisFilterForm
|
||||
@ -2518,9 +2565,11 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class PowerPanelListView(generic.ObjectListView):
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
filterset_form = forms.PowerPanelFilterForm
|
||||
table = tables.PowerPanelTable
|
||||
@ -2569,8 +2618,8 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.prefetch_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
rack_count=Count('powerfeeds')
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
powerfeed_count=count_related(PowerFeed, 'power_panel')
|
||||
)
|
||||
filterset = filters.PowerPanelFilterSet
|
||||
table = tables.PowerPanelTable
|
||||
|
||||
|
@ -2,7 +2,6 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from utilities.forms import LaxURLField
|
||||
from .choices import CustomFieldTypeChoices
|
||||
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
|
||||
|
||||
|
||||
@ -30,8 +29,8 @@ class WebhookForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'obj_type' in self.fields:
|
||||
order_content_types(self.fields['obj_type'])
|
||||
if 'content_types' in self.fields:
|
||||
order_content_types(self.fields['content_types'])
|
||||
|
||||
|
||||
@admin.register(Webhook)
|
||||
@ -41,12 +40,12 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
'ssl_verification',
|
||||
]
|
||||
list_filter = [
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
|
||||
]
|
||||
form = WebhookForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'obj_type', 'enabled')
|
||||
'fields': ('name', 'content_types', 'enabled')
|
||||
}),
|
||||
('Events', {
|
||||
'fields': ('type_create', 'type_update', 'type_delete')
|
||||
@ -63,7 +62,7 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
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
|
||||
exclude = []
|
||||
widgets = {
|
||||
'default': forms.TextInput(),
|
||||
'validation_regex': forms.Textarea(
|
||||
attrs={
|
||||
'cols': 80,
|
||||
|
@ -26,15 +26,8 @@ class CustomFieldDefaultValues:
|
||||
# Populate the default value for each CustomField
|
||||
value = {}
|
||||
for field in fields:
|
||||
if field.default:
|
||||
if field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
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
|
||||
if field.default is not None:
|
||||
value[field.name] = field.default
|
||||
else:
|
||||
value[field.name] = None
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
@ -12,15 +11,17 @@ from rq import Worker
|
||||
|
||||
from extras import filters
|
||||
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.scripts import get_script, get_scripts, run_script
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import copy_safe_request
|
||||
from utilities.utils import copy_safe_request, count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -101,8 +102,8 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
tagged_items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filters.TagFilterSet
|
||||
|
||||
|
@ -2,6 +2,7 @@ import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.forms import DateField, IntegerField, NullBooleanField
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
def __init__(self, custom_field, *args, **kwargs):
|
||||
self.custom_field = custom_field
|
||||
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
self.field_class = IntegerField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
self.field_class = NullBooleanField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
self.field_class = DateField
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
# Skip filter on empty value
|
||||
if value is None or not value.strip():
|
||||
return queryset
|
||||
|
||||
# Apply the assigned filter logic (exact or loose)
|
||||
if (
|
||||
self.custom_field.type in EXACT_FILTER_TYPES or
|
||||
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||
):
|
||||
kwargs = {f'custom_field_data__{self.field_name}': value}
|
||||
else:
|
||||
kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
|
||||
|
||||
return queryset.filter(**kwargs)
|
||||
if custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
class CustomFieldModelFilterSet(django_filters.FilterSet):
|
||||
@ -78,7 +76,7 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'default', 'weight']
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
|
||||
|
||||
class ExportTemplateFilterSet(BaseFilterSet):
|
||||
|
@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields.append(field_name)
|
||||
|
||||
def save(self, commit=True):
|
||||
def clean(self):
|
||||
|
||||
# Save custom field data on instance
|
||||
for cf_name in self.custom_fields:
|
||||
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):
|
||||
|
@ -25,12 +25,18 @@ class Command(BaseCommand):
|
||||
help = "Start the Django shell with all NetBox models already imported"
|
||||
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):
|
||||
for app, models in self.django_models.items():
|
||||
app_name = apps.get_app_config(app).verbose_name
|
||||
print('{}:'.format(app_name))
|
||||
print(f'{app_name}:')
|
||||
for m in models:
|
||||
print(' {}'.format(m))
|
||||
print(f' {m}')
|
||||
|
||||
def get_namespace(self):
|
||||
namespace = {}
|
||||
@ -46,7 +52,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Constants
|
||||
try:
|
||||
app_constants = sys.modules['{}.constants'.format(app)]
|
||||
app_constants = sys.modules[f'{app}.constants']
|
||||
for name in dir(app_constants):
|
||||
namespace[name] = getattr(app_constants, name)
|
||||
except KeyError:
|
||||
@ -64,5 +70,10 @@ class Command(BaseCommand):
|
||||
return namespace
|
||||
|
||||
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())
|
||||
return shell
|
||||
|
@ -34,6 +34,12 @@ class Migration(migrations.Migration):
|
||||
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
|
||||
migrations.RenameField(
|
||||
model_name='customfield',
|
||||
|
@ -16,6 +16,28 @@ def deserialize_value(field, 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):
|
||||
"""
|
||||
Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
|
||||
@ -73,6 +95,9 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=migrate_customfield_defaults
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=migrate_customfieldchoices
|
||||
),
|
||||
|
@ -8,6 +8,15 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='CustomField',
|
||||
name='default',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='CustomField',
|
||||
old_name='default2',
|
||||
new_name='default'
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='CustomFieldChoice',
|
||||
),
|
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal file
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal 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',
|
||||
),
|
||||
]
|
@ -1,6 +1,6 @@
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
from django import forms
|
||||
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 '
|
||||
'matches the entire field.'
|
||||
)
|
||||
default = models.CharField(
|
||||
max_length=100,
|
||||
default = models.JSONField(
|
||||
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(
|
||||
default=100,
|
||||
@ -171,6 +172,15 @@ class CustomField(models.Model):
|
||||
obj.save()
|
||||
|
||||
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
|
||||
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
raise ValidationError({
|
||||
@ -232,8 +242,6 @@ class CustomField(models.Model):
|
||||
(True, 'True'),
|
||||
(False, 'False'),
|
||||
)
|
||||
if initial is not None:
|
||||
initial = bool(initial)
|
||||
field = forms.NullBooleanField(
|
||||
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||
)
|
||||
@ -309,10 +317,11 @@ class CustomField(models.Model):
|
||||
|
||||
# Validate date
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
if type(value) is not date:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
|
||||
# Validate selected choice
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
|
@ -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.
|
||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||
"""
|
||||
# TODO: Rename obj_type to content_types (see #4711)
|
||||
obj_type = models.ManyToManyField(
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='webhooks',
|
||||
verbose_name='Object types',
|
||||
|
@ -3,7 +3,6 @@ import inspect
|
||||
from packaging import version
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import get_template
|
||||
|
||||
@ -71,10 +70,10 @@ class PluginConfig(AppConfig):
|
||||
register_menu_items(self.verbose_name, menu_items)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, user_config):
|
||||
def validate(cls, user_config, netbox_version):
|
||||
|
||||
# Enforce version constraints
|
||||
current_version = version.parse(settings.VERSION)
|
||||
current_version = version.parse(netbox_version)
|
||||
if cls.min_version is not None:
|
||||
min_version = version.parse(cls.min_version)
|
||||
if current_version < min_version:
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.db.models import OuterRef, Subquery, Q
|
||||
|
||||
from extras.models.tags import TaggedItem
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
@ -81,11 +82,25 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
|
||||
def _get_config_context_filters(self):
|
||||
# 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(
|
||||
Q(platforms=OuterRef('platform')) | Q(platforms=None),
|
||||
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=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,
|
||||
)
|
||||
|
||||
|
@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([ct])
|
||||
|
||||
# Create a select custom field on the Site model
|
||||
cf_select = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||
form_data = {
|
||||
@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-1',
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'cf_my_field': 'ABC',
|
||||
'cf_my_field_select': 'Bar',
|
||||
'tags': [tag.pk for tag in tags],
|
||||
}
|
||||
|
||||
@ -54,6 +65,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc_list[0].changed_object, site)
|
||||
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
'slug': 'test-site-x',
|
||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||
'cf_my_field': 'DEF',
|
||||
'cf_my_field_select': 'Foo',
|
||||
'tags': [tags[2].pk],
|
||||
}
|
||||
|
||||
@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||
|
||||
def test_delete_object(self):
|
||||
@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
|
||||
@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([ct])
|
||||
|
||||
# Create a select custom field on the Site model
|
||||
cf_select = CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field_select',
|
||||
required=False,
|
||||
choices=['Bar', 'Foo']
|
||||
)
|
||||
cf_select.save()
|
||||
cf_select.content_types.set([ct])
|
||||
|
||||
# Create some tags
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 1'},
|
||||
@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
'my_field': 'DEF',
|
||||
'my_field_select': 'Foo',
|
||||
},
|
||||
'tags': [
|
||||
{'name': 'Tag 3'}
|
||||
@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
|
||||
name='Test Site 1',
|
||||
slug='test-site-1',
|
||||
custom_field_data={
|
||||
'my_field': 'ABC'
|
||||
'my_field': 'ABC',
|
||||
'my_field_select': 'Bar'
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||
self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
|
||||
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.filters import SiteFilterSet
|
||||
from dcim.forms import SiteCSVForm
|
||||
from dcim.models import Site, Rack
|
||||
from extras.choices import *
|
||||
@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
site.cf['baz'] = 'def'
|
||||
site.clean()
|
||||
|
||||
|
||||
class CustomFieldFilterTest(TestCase):
|
||||
queryset = Site.objects.all()
|
||||
filterset = SiteFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Integer filtering
|
||||
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Boolean filtering
|
||||
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Exact text filtering
|
||||
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Loose text filtering
|
||||
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Date filtering
|
||||
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Exact URL filtering
|
||||
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Loose URL filtering
|
||||
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Selection filtering
|
||||
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site 1', slug='site-1', custom_field_data={
|
||||
'cf1': 100,
|
||||
'cf2': True,
|
||||
'cf3': 'foo',
|
||||
'cf4': 'foo',
|
||||
'cf5': '2016-06-26',
|
||||
'cf6': 'http://foo.example.com/',
|
||||
'cf7': 'http://foo.example.com/',
|
||||
'cf8': 'Foo',
|
||||
}),
|
||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||
'cf1': 200,
|
||||
'cf2': False,
|
||||
'cf3': 'foobar',
|
||||
'cf4': 'foobar',
|
||||
'cf5': '2016-06-27',
|
||||
'cf6': 'http://bar.example.com/',
|
||||
'cf7': 'http://bar.example.com/',
|
||||
'cf8': 'Bar',
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||
}),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_boolean(self):
|
||||
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_text(self):
|
||||
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_date(self):
|
||||
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_url(self):
|
||||
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
||||
|
@ -321,3 +321,46 @@ class ConfigContextTest(TestCase):
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
||||
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())
|
||||
|
@ -86,21 +86,19 @@ class PluginTest(TestCase):
|
||||
"""
|
||||
self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS)
|
||||
|
||||
@override_settings(VERSION='0.9')
|
||||
def test_min_version(self):
|
||||
"""
|
||||
Check enforcement of minimum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({})
|
||||
dummy_config.validate({}, '0.9')
|
||||
|
||||
@override_settings(VERSION='10.0')
|
||||
def test_max_version(self):
|
||||
"""
|
||||
Check enforcement of maximum NetBox version.
|
||||
"""
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
dummy_config.validate({})
|
||||
dummy_config.validate({}, '10.0')
|
||||
|
||||
def test_required_settings(self):
|
||||
"""
|
||||
@ -110,11 +108,11 @@ class PluginTest(TestCase):
|
||||
required_settings = ['foo']
|
||||
|
||||
# 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
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
DummyConfigWithRequiredSettings.validate({})
|
||||
DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
|
||||
|
||||
def test_default_settings(self):
|
||||
"""
|
||||
@ -127,10 +125,10 @@ class PluginTest(TestCase):
|
||||
|
||||
# Populate the default value if setting has not been specified
|
||||
user_config = {}
|
||||
DummyConfigWithDefaultSettings.validate(user_config)
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 123)
|
||||
|
||||
# Don't overwrite specified values
|
||||
user_config = {'bar': 456}
|
||||
DummyConfigWithDefaultSettings.validate(user_config)
|
||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||
self.assertEqual(user_config['bar'], 456)
|
||||
|
@ -39,7 +39,7 @@ class WebhookTest(APITestCase):
|
||||
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
))
|
||||
for webhook in webhooks:
|
||||
webhook.obj_type.set([site_ct])
|
||||
webhook.content_types.set([site_ct])
|
||||
|
||||
def test_enqueue_webhook_create(self):
|
||||
# Create an object via the REST API
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django import template
|
||||
from django.contrib import messages
|
||||
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.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
@ -12,11 +12,11 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import copy_safe_request, shallow_compare_dict
|
||||
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
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 .scripts import get_scripts, run_script
|
||||
|
||||
@ -27,8 +27,8 @@ from .scripts import get_scripts, run_script
|
||||
|
||||
class TagListView(generic.ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
filterset = filters.TagFilterSet
|
||||
filterset_form = forms.TagFilterForm
|
||||
table = tables.TagTable
|
||||
@ -52,16 +52,16 @@ class TagBulkImportView(generic.BulkImportView):
|
||||
|
||||
class TagBulkEditView(generic.BulkEditView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
|
||||
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
items=count_related(TaggedItem, 'tag')
|
||||
)
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
|
@ -35,13 +35,13 @@ def enqueue_webhooks(instance, user, request_id, action):
|
||||
return
|
||||
|
||||
# Retrieve any applicable Webhooks
|
||||
obj_type = ContentType.objects.get_for_model(instance)
|
||||
content_type = ContentType.objects.get_for_model(instance)
|
||||
action_flag = {
|
||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||
}[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():
|
||||
# Get the Model's API serializer class and serialize the object
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
@ -13,7 +12,7 @@ from ipam import filters
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -33,9 +32,9 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
|
||||
'import_targets', 'export_targets', 'tags'
|
||||
).annotate(
|
||||
ipaddress_count=get_subquery(IPAddress, 'vrf'),
|
||||
prefix_count=get_subquery(Prefix, 'vrf')
|
||||
).order_by(*VRF._meta.ordering)
|
||||
ipaddress_count=count_related(IPAddress, 'vrf'),
|
||||
prefix_count=count_related(Prefix, 'vrf')
|
||||
)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilterSet
|
||||
|
||||
@ -56,8 +55,8 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=Count('aggregates')
|
||||
).order_by(*RIR._meta.ordering)
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilterSet
|
||||
|
||||
@ -78,9 +77,9 @@ class AggregateViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
).order_by(*Role._meta.ordering)
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilterSet
|
||||
|
||||
@ -92,7 +91,7 @@ class RoleViewSet(ModelViewSet):
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.prefetch_related(
|
||||
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
|
||||
).order_by(*Prefix._meta.ordering)
|
||||
)
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
@ -262,7 +261,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
).order_by(*IPAddress._meta.ordering)
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filters.IPAddressFilterSet
|
||||
|
||||
@ -273,8 +272,8 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filters.VLANGroupFilterSet
|
||||
|
||||
@ -287,8 +286,8 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'vlan')
|
||||
).order_by(*VLAN._meta.ordering)
|
||||
prefix_count=count_related(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
||||
|
@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
@ -856,6 +857,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=IPAddressStatusChoices,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
role = CSVChoiceField(
|
||||
@ -888,7 +890,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
@ -10,7 +10,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0010_custom_field_data'),
|
||||
('extras', '0052_delete_customfieldchoice_customfieldvalue'),
|
||||
('extras', '0052_customfield_cleanup'),
|
||||
('ipam', '0040_service_drop_port'),
|
||||
]
|
||||
|
||||
|
@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% if record.children %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a>
|
||||
{% else %}
|
||||
<span class="text-nowrap" style="padding-left: {{ record.parents }}9px">
|
||||
{% endif %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
</span>
|
||||
{% load helpers %}
|
||||
{% for i in record.parents|as_range %}
|
||||
<i class="mdi mdi-circle-small"></i>
|
||||
{% endfor %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
{% if record.untagged_vlan_id == object.pk %}
|
||||
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
|
@ -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.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
|
||||
from dcim.models import Device, Interface
|
||||
from netbox.views import generic
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from . import filters, forms, tables
|
||||
from .constants import *
|
||||
@ -139,7 +139,9 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
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_form = forms.RIRFilterForm
|
||||
table = tables.RIRTable
|
||||
@ -162,7 +164,9 @@ class RIRBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
table = tables.RIRTable
|
||||
|
||||
@ -174,7 +178,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
class AggregateListView(generic.ObjectListView):
|
||||
queryset = Aggregate.objects.annotate(
|
||||
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
|
||||
).order_by(*Aggregate._meta.ordering)
|
||||
)
|
||||
filterset = filters.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateDetailTable
|
||||
@ -273,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RoleListView(generic.ObjectListView):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
vlan_count=count_related(VLAN, 'role')
|
||||
)
|
||||
table = tables.RoleTable
|
||||
|
||||
@ -628,9 +632,9 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
filterset_form = forms.VLANGroupFilterForm
|
||||
table = tables.VLANGroupTable
|
||||
@ -653,8 +657,8 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
filterset = filters.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
|
||||
|
@ -1,10 +1,45 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
from django.utils.encoding import force_str
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.metadata import SimpleMetadata
|
||||
from rest_framework.request import clone_request
|
||||
|
||||
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):
|
||||
field_info = super().get_field_info(field)
|
||||
|
@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
|
||||
# Text to include on the login page above the login form. HTML is allowed.
|
||||
BANNER_LOGIN = ''
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
@ -183,7 +183,7 @@ NAPALM_PASSWORD = ''
|
||||
# NAPALM timeout (in seconds). (Default: 30)
|
||||
NAPALM_TIMEOUT = 30
|
||||
|
||||
# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
|
||||
# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
|
||||
# be provided as a dictionary.
|
||||
NAPALM_ARGS = {}
|
||||
|
||||
|
@ -7,8 +7,8 @@ ALLOWED_HOSTS = ['*']
|
||||
|
||||
DATABASE = {
|
||||
'NAME': 'netbox',
|
||||
'USER': '',
|
||||
'PASSWORD': '',
|
||||
'USER': 'netbox',
|
||||
'PASSWORD': 'netbox',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '',
|
||||
'CONN_MAX_AGE': 300,
|
||||
|
@ -23,7 +23,7 @@ from secrets.tables import SecretTable
|
||||
from tenancy.filters import TenantFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.tables import TenantTable
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
|
||||
@ -33,8 +33,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=Count('circuits')
|
||||
).order_by(*Provider._meta.ordering),
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': ProviderFilterSet,
|
||||
'table': ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
@ -61,17 +61,21 @@ SEARCH_TYPES = OrderedDict((
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackgroup', {
|
||||
'queryset': RackGroup.objects.prefetch_related('site').annotate(
|
||||
rack_count=Count('racks')
|
||||
).order_by(*RackGroup._meta.ordering),
|
||||
'queryset': RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
Rack,
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': RackGroupFilterSet,
|
||||
'table': RackGroupTable,
|
||||
'url': 'dcim:rackgroup_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=Count('instances')
|
||||
).order_by(*DeviceType._meta.ordering),
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': DeviceTypeFilterSet,
|
||||
'table': DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
@ -86,8 +90,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=Count('members', distinct=True)
|
||||
).order_by(*VirtualChassis._meta.ordering),
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': VirtualChassisFilterSet,
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
@ -107,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
|
||||
# Virtualization
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': ClusterFilterSet,
|
||||
'table': ClusterTable,
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.10-beta2'
|
||||
VERSION = '2.10.3-dev'
|
||||
|
||||
# Hostname
|
||||
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_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||
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
|
||||
if 'caching' not in REDIS:
|
||||
@ -470,6 +467,7 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
|
||||
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'netbox.api.authentication.TokenPermissions',
|
||||
@ -612,7 +610,7 @@ for plugin_name in PLUGINS:
|
||||
# Validate user-provided configuration settings and assign defaults
|
||||
if plugin_name not in PLUGINS_CONFIG:
|
||||
PLUGINS_CONFIG[plugin_name] = {}
|
||||
plugin_config.validate(PLUGINS_CONFIG[plugin_name])
|
||||
plugin_config.validate(PLUGINS_CONFIG[plugin_name], VERSION)
|
||||
|
||||
# Add middleware
|
||||
plugin_middleware = plugin_config.middleware
|
||||
|
@ -212,6 +212,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['view']
|
||||
)
|
||||
@ -239,6 +240,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['view']
|
||||
)
|
||||
@ -266,6 +268,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['add']
|
||||
)
|
||||
@ -295,6 +298,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['change']
|
||||
)
|
||||
@ -330,6 +334,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
# Assign object permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'site__name': 'Site 1'},
|
||||
actions=['delete']
|
||||
)
|
||||
|
@ -1,11 +1,10 @@
|
||||
// Inteface filtering
|
||||
$('input.interface-filter').on('input', function() {
|
||||
var filter = new RegExp(this.value);
|
||||
var interface;
|
||||
let filter = new RegExp(this.value);
|
||||
let interface;
|
||||
|
||||
for (interface of $('table > tbody > tr')) {
|
||||
// Slice off 'interface_' at the start of the ID
|
||||
if (filter.test(interface.id.slice(10))) {
|
||||
if (filter.test(interface.getAttribute('data-name'))) {
|
||||
// Match the toggle in case the filter now matches the interface
|
||||
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
|
||||
$(interface).show();
|
||||
|
@ -1,7 +1,6 @@
|
||||
import base64
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseBadRequest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@ -13,6 +12,7 @@ from netbox.api.views import ModelViewSet
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.utils import count_related
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@ -35,8 +35,8 @@ class SecretsRootView(APIRootView):
|
||||
|
||||
class SecretRoleViewSet(ModelViewSet):
|
||||
queryset = SecretRole.objects.annotate(
|
||||
secret_count=Count('secrets')
|
||||
).order_by(*SecretRole._meta.ordering)
|
||||
secret_count=count_related(Secret, 'role')
|
||||
)
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
filterset_class = filters.SecretRoleFilterSet
|
||||
|
||||
|
@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
|
||||
raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")
|
||||
@ -143,31 +144,56 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
|
||||
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(
|
||||
queryset=SecretRole.objects.all(),
|
||||
to_field_name='name',
|
||||
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(
|
||||
help_text='Plaintext secret data'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = Secret.csv_headers
|
||||
fields = ['role', 'name', 'plaintext', 'device', 'virtual_machine']
|
||||
help_texts = {
|
||||
'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):
|
||||
|
||||
# Set device/VM assignment
|
||||
self.instance.assigned_object = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
|
||||
|
||||
s = super().save(*args, **kwargs)
|
||||
|
||||
# Set plaintext on instance
|
||||
s.plaintext = str(self.cleaned_data['plaintext'])
|
||||
|
||||
return s
|
||||
|
||||
|
||||
|
@ -30,15 +30,21 @@ class SecretRoleTable(BaseTable):
|
||||
|
||||
class SecretTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
id = tables.Column( # Provides a link to the secret
|
||||
linkify=True
|
||||
)
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Assigned object'
|
||||
)
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='secrets:secret_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Secret
|
||||
fields = ('pk', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
|
||||
default_columns = ('pk', 'assigned_object', 'role', 'name', 'last_updated')
|
||||
fields = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
|
||||
default_columns = ('pk', 'id', 'assigned_object', 'role', 'name', 'last_updated')
|
||||
|
@ -103,10 +103,10 @@ class SecretTestCase(
|
||||
|
||||
device = Device.objects.get(name='Device 1')
|
||||
csv_data = (
|
||||
"assigned_object_type,assigned_object_id,role,name,plaintext",
|
||||
f"device,{device.pk},Secret Role 1,Secret 4,abcdefghij",
|
||||
f"device,{device.pk},Secret Role 1,Secret 5,abcdefghij",
|
||||
f"device,{device.pk},Secret Role 1,Secret 6,abcdefghij",
|
||||
"device,role,name,plaintext",
|
||||
f"{device.name},Secret Role 1,Secret 4,abcdefghij",
|
||||
f"{device.name},Secret Role 1,Secret 5,abcdefghij",
|
||||
f"{device.name},Secret Role 1,Secret 6,abcdefghij",
|
||||
)
|
||||
|
||||
# Set the session_key cookie on the request
|
||||
|
@ -2,12 +2,12 @@ import base64
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .models import SecretRole, Secret, SessionKey, UserKey
|
||||
|
||||
@ -27,7 +27,9 @@ def get_session_key(request):
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -47,7 +49,9 @@ class SecretRoleBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -94,13 +98,14 @@ class SecretEditView(generic.ObjectEditView):
|
||||
|
||||
if form.is_valid():
|
||||
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
|
||||
if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None:
|
||||
# We must have a session key in order to set the plaintext of a Secret
|
||||
if form.cleaned_data['plaintext'] and session_key is None:
|
||||
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.")
|
||||
|
||||
else:
|
||||
elif form.cleaned_data['plaintext']:
|
||||
master_key = None
|
||||
try:
|
||||
sk = SessionKey.objects.get(userkey__user=request.user)
|
||||
@ -111,19 +116,18 @@ class SecretEditView(generic.ObjectEditView):
|
||||
|
||||
if master_key is not None:
|
||||
logger.debug("Successfully resolved master key for encryption")
|
||||
secret = form.save(commit=False)
|
||||
if form.cleaned_data['plaintext']:
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
form.save_m2m()
|
||||
|
||||
msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
|
||||
logger.info(f"{msg} {secret} (PK: {secret.pk})")
|
||||
msg = '{} <a href="{}">{}</a>'.format(msg, secret.get_absolute_url(), escape(secret))
|
||||
messages.success(request, mark_safe(msg))
|
||||
secret.save()
|
||||
form.save_m2m()
|
||||
|
||||
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:
|
||||
logger.debug("Form validation failed")
|
||||
|
@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<div class="col-xs-4 text-right noprint">
|
||||
<p class="text-muted">
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> ·
|
||||
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>
|
||||
|
@ -14,7 +14,7 @@
|
||||
<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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<li>{{ object }}</li>
|
||||
|
@ -9,7 +9,9 @@
|
||||
<ol class="breadcrumb">
|
||||
<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="{% 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>
|
||||
</ol>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -9,18 +9,18 @@
|
||||
{% include 'responsive_table.html' %}
|
||||
<div class="panel-footer noprint">
|
||||
{% 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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
{% endif %}
|
||||
<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>
|
||||
Add {{ title }}
|
||||
</a>
|
||||
|
@ -331,12 +331,16 @@
|
||||
<div class="col-md-6">
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
<h4>Front</h4>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
<h4>Rear</h4>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
@ -26,7 +26,8 @@
|
||||
{% if page %}
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% for rack in page %}
|
||||
<div style="display: inline-block; width: 266px">
|
||||
<div style="display: inline-block; margin-right: 12px; width: 254px">
|
||||
<div style="margin-left: 30px">
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
@ -36,7 +37,7 @@
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</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="text-center">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -137,7 +137,7 @@
|
||||
<td>
|
||||
{% if object.physical_address %}
|
||||
<div class="pull-right noprint">
|
||||
<a href="http://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
|
||||
<a href="https://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
|
||||
<i class="mdi mdi-map-marker"></i> Map it
|
||||
</a>
|
||||
</div>
|
||||
@ -156,7 +156,7 @@
|
||||
<td>
|
||||
{% if object.latitude and object.longitude %}
|
||||
<div class="pull-right noprint">
|
||||
<a href="http://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
|
||||
<a href="https://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
|
||||
<i class="mdi mdi-map-marker"></i> Map it
|
||||
</a>
|
||||
</div>
|
||||
|
@ -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 %}">
|
||||
<strong>
|
||||
<a href="{% url 'dcim:cable' pk=cable.pk %}">
|
||||
@ -5,11 +7,13 @@
|
||||
</a>
|
||||
</strong><br />
|
||||
{% if cable.type %}
|
||||
{{ cable.get_type_display|default:"" }}
|
||||
{% if cable.length %}
|
||||
({{ cable.length }} {{ cable.get_length_unit_display }})
|
||||
{% endif %}
|
||||
<br />
|
||||
{{ cable.get_type_display|default:"" }}<br />
|
||||
{% 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>
|
||||
|
@ -67,7 +67,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% 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>
|
||||
</button>
|
||||
<div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog">
|
||||
|
@ -78,7 +78,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
<td>{{ vrf.description|placeholder }}</td>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% if secrets %}
|
||||
<form id="secret_form">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for secret in secrets %}
|
||||
<tr>
|
||||
|
@ -317,5 +317,6 @@
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/interface_filtering.js' %}?v{{ settings.VERSION }}"></script>
|
||||
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
|
||||
<script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -7,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
|
||||
from netbox.api.views import ModelViewSet
|
||||
from tenancy import filters
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
|
||||
@ -44,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
|
||||
queryset = Tenant.objects.prefetch_related(
|
||||
'group', 'tags'
|
||||
).annotate(
|
||||
circuit_count=get_subquery(Circuit, 'tenant'),
|
||||
device_count=get_subquery(Device, 'tenant'),
|
||||
ipaddress_count=get_subquery(IPAddress, 'tenant'),
|
||||
prefix_count=get_subquery(Prefix, 'tenant'),
|
||||
rack_count=get_subquery(Rack, 'tenant'),
|
||||
site_count=get_subquery(Site, 'tenant'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'tenant'),
|
||||
vlan_count=get_subquery(VLAN, 'tenant'),
|
||||
vrf_count=get_subquery(VRF, 'tenant')
|
||||
circuit_count=count_related(Circuit, 'tenant'),
|
||||
device_count=count_related(Device, 'tenant'),
|
||||
ipaddress_count=count_related(IPAddress, 'tenant'),
|
||||
prefix_count=count_related(Prefix, 'tenant'),
|
||||
rack_count=count_related(Rack, 'tenant'),
|
||||
site_count=count_related(Site, 'tenant'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'tenant'),
|
||||
vlan_count=count_related(VLAN, 'tenant'),
|
||||
vrf_count=count_related(VRF, 'tenant')
|
||||
)
|
||||
serializer_class = serializers.TenantSerializer
|
||||
filterset_class = filters.TenantFilterSet
|
||||
|
@ -19,9 +19,23 @@ class UserSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
'id', 'url', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
|
||||
'groups',
|
||||
'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
|
||||
'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):
|
||||
|
@ -21,15 +21,19 @@ class UserTest(APIViewTestCases.APIViewTestCase):
|
||||
model = User
|
||||
view_namespace = 'users'
|
||||
brief_fields = ['id', 'url', 'username']
|
||||
validation_excluded_fields = ['password']
|
||||
create_data = [
|
||||
{
|
||||
'username': 'User_4',
|
||||
'password': 'password4',
|
||||
},
|
||||
{
|
||||
'username': 'User_5',
|
||||
'password': 'password5',
|
||||
},
|
||||
{
|
||||
'username': 'User_6',
|
||||
'password': 'password6',
|
||||
},
|
||||
]
|
||||
|
||||
@ -94,7 +98,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
object_type = ContentType.objects.get(app_label='dcim', model='device')
|
||||
|
||||
for i in range(0, 3):
|
||||
for i in range(3):
|
||||
objectpermission = ObjectPermission(
|
||||
name=f'Permission {i+1}',
|
||||
actions=['view', 'add', 'change', 'delete'],
|
||||
|
@ -6,12 +6,11 @@ from io import StringIO
|
||||
import django_filters
|
||||
from django import forms
|
||||
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.forms import BoundField
|
||||
from django.urls import reverse
|
||||
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.choices import unpack_grouped_choices
|
||||
from utilities.validators import EnhancedURLValidator
|
||||
from . import widgets
|
||||
@ -21,6 +20,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||
__all__ = (
|
||||
'CommentField',
|
||||
'CSVChoiceField',
|
||||
'CSVContentTypeField',
|
||||
'CSVDataField',
|
||||
'CSVModelChoiceField',
|
||||
'DynamicModelChoiceField',
|
||||
@ -135,12 +135,32 @@ class CSVModelChoiceField(forms.ModelChoiceField):
|
||||
def to_python(self, value):
|
||||
try:
|
||||
return super().to_python(value)
|
||||
except MultipleObjectsReturned as e:
|
||||
except MultipleObjectsReturned:
|
||||
raise forms.ValidationError(
|
||||
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):
|
||||
"""
|
||||
A field which allows for numeric range expansion
|
||||
|
@ -208,6 +208,18 @@ def split(string, sep=','):
|
||||
return string.split(sep)
|
||||
|
||||
|
||||
@register.filter()
|
||||
def as_range(n):
|
||||
"""
|
||||
Return a range of n items.
|
||||
"""
|
||||
try:
|
||||
int(n)
|
||||
except TypeError:
|
||||
return list()
|
||||
return range(n)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
@ -93,6 +93,7 @@ class APIViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': instance1.pk},
|
||||
actions=['view']
|
||||
)
|
||||
@ -108,6 +109,15 @@ class APIViewTestCases:
|
||||
url = self._get_detail_url(instance2)
|
||||
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):
|
||||
brief_fields = []
|
||||
|
||||
@ -160,6 +170,7 @@ class APIViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk__in': [instance1.pk, instance2.pk]},
|
||||
actions=['view']
|
||||
)
|
||||
@ -172,8 +183,17 @@ class APIViewTestCases:
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
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):
|
||||
create_data = []
|
||||
validation_excluded_fields = []
|
||||
|
||||
def test_create_object_without_permission(self):
|
||||
"""
|
||||
@ -192,6 +212,7 @@ class APIViewTestCases:
|
||||
"""
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -205,6 +226,7 @@ class APIViewTestCases:
|
||||
self.assertInstanceEqual(
|
||||
self._get_queryset().get(pk=response.data['id']),
|
||||
self.create_data[0],
|
||||
exclude=self.validation_excluded_fields,
|
||||
api=True
|
||||
)
|
||||
|
||||
@ -214,6 +236,7 @@ class APIViewTestCases:
|
||||
"""
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -227,17 +250,20 @@ class APIViewTestCases:
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(self.create_data))
|
||||
for i, obj in enumerate(response.data):
|
||||
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):
|
||||
self.assertInstanceEqual(
|
||||
self._get_queryset().get(pk=obj['id']),
|
||||
self.create_data[i],
|
||||
exclude=self.validation_excluded_fields,
|
||||
api=True
|
||||
)
|
||||
|
||||
class UpdateObjectViewTestCase(APITestCase):
|
||||
update_data = {}
|
||||
bulk_update_data = None
|
||||
validation_excluded_fields = []
|
||||
|
||||
def test_update_object_without_permission(self):
|
||||
"""
|
||||
@ -261,6 +287,7 @@ class APIViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -270,7 +297,12 @@ class APIViewTestCases:
|
||||
response = self.client.patch(url, update_data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
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):
|
||||
"""
|
||||
@ -281,6 +313,7 @@ class APIViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -323,6 +356,7 @@ class APIViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['delete']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -339,6 +373,7 @@ class APIViewTestCases:
|
||||
"""
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['delete']
|
||||
)
|
||||
obj_perm.save()
|
||||
|
@ -100,7 +100,7 @@ class TestCase(_TestCase):
|
||||
"""
|
||||
for name in names:
|
||||
ct, action = resolve_permission_ct(name)
|
||||
obj_perm = ObjectPermission(actions=[action])
|
||||
obj_perm = ObjectPermission(name=name, actions=[action])
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
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}"
|
||||
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
|
||||
in the dictionary.
|
||||
|
||||
:instance: Python object instance
|
||||
: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 instance: Python object instance
|
||||
:param data: Dictionary of test data used to define 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 = {
|
||||
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)
|
||||
@ -240,6 +245,7 @@ class ViewTestCases:
|
||||
|
||||
# Add model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['view']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -255,6 +261,7 @@ class ViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': instance1.pk},
|
||||
actions=['view']
|
||||
)
|
||||
@ -307,6 +314,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign unconstrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -331,6 +339,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': 0}, # Dummy permission to deny all
|
||||
actions=['add']
|
||||
)
|
||||
@ -391,6 +400,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -414,6 +424,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': instance1.pk},
|
||||
actions=['change']
|
||||
)
|
||||
@ -467,6 +478,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['delete']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -491,6 +503,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': instance1.pk},
|
||||
actions=['delete']
|
||||
)
|
||||
@ -544,6 +557,7 @@ class ViewTestCases:
|
||||
|
||||
# Add model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['view']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -565,6 +579,7 @@ class ViewTestCases:
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': instance1.pk},
|
||||
actions=['view']
|
||||
)
|
||||
@ -614,6 +629,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign non-constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add'],
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -637,6 +653,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add'],
|
||||
constraints={'pk': 0} # Dummy constraint to deny all
|
||||
)
|
||||
@ -692,6 +709,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['add']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -714,6 +732,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': 0}, # Dummy permission to deny all
|
||||
actions=['add']
|
||||
)
|
||||
@ -770,6 +789,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -799,6 +819,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={attr_name: value},
|
||||
actions=['change']
|
||||
)
|
||||
@ -851,6 +872,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign unconstrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['delete']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -873,6 +895,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'pk': 0}, # Dummy permission to deny all
|
||||
actions=['delete']
|
||||
)
|
||||
@ -930,6 +953,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign model-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
@ -953,6 +977,7 @@ class ViewTestCases:
|
||||
|
||||
# Assign constrained permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
constraints={'name__regex': '[^X]$'},
|
||||
actions=['change']
|
||||
)
|
||||
|
@ -5,6 +5,7 @@ from itertools import count, groupby
|
||||
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from jinja2 import Environment
|
||||
|
||||
from dcim.choices import CableLengthUnitChoices
|
||||
@ -65,7 +66,7 @@ def dynamic_import(name):
|
||||
return mod
|
||||
|
||||
|
||||
def get_subquery(model, field):
|
||||
def count_related(model, field):
|
||||
"""
|
||||
Return a Subquery suitable for annotating a child object count.
|
||||
"""
|
||||
@ -79,7 +80,7 @@ def get_subquery(model, field):
|
||||
).values('c')
|
||||
)
|
||||
|
||||
return subquery
|
||||
return Coalesce(subquery, 0)
|
||||
|
||||
|
||||
def serialize_object(obj, extra=None, exclude=None):
|
||||
|
@ -116,3 +116,16 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
|
||||
'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)
|
||||
|
@ -1,9 +1,8 @@
|
||||
from django.db.models import Count
|
||||
from rest_framework.routers import APIRootView
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from virtualization import filters
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from . import serializers
|
||||
@ -23,16 +22,16 @@ class VirtualizationRootView(APIRootView):
|
||||
|
||||
class ClusterTypeViewSet(ModelViewSet):
|
||||
queryset = ClusterType.objects.annotate(
|
||||
cluster_count=Count('clusters')
|
||||
).order_by(*ClusterType._meta.ordering)
|
||||
cluster_count=count_related(Cluster, 'type')
|
||||
)
|
||||
serializer_class = serializers.ClusterTypeSerializer
|
||||
filterset_class = filters.ClusterTypeFilterSet
|
||||
|
||||
|
||||
class ClusterGroupViewSet(ModelViewSet):
|
||||
queryset = ClusterGroup.objects.annotate(
|
||||
cluster_count=Count('clusters')
|
||||
).order_by(*ClusterGroup._meta.ordering)
|
||||
cluster_count=count_related(Cluster, 'group')
|
||||
)
|
||||
serializer_class = serializers.ClusterGroupSerializer
|
||||
filterset_class = filters.ClusterGroupFilterSet
|
||||
|
||||
@ -41,9 +40,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
queryset = Cluster.objects.prefetch_related(
|
||||
'type', 'group', 'tenant', 'site', 'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'cluster')
|
||||
).order_by(*Cluster._meta.ordering)
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'cluster')
|
||||
)
|
||||
serializer_class = serializers.ClusterSerializer
|
||||
filterset_class = filters.ClusterFilterSet
|
||||
|
||||
@ -52,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
queryset = VirtualMachine.objects.prefetch_related(
|
||||
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
|
||||
)
|
||||
|
@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
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 extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
|
||||
@ -543,10 +543,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
display_field='display_name',
|
||||
brief_mode=False,
|
||||
query_params={
|
||||
@ -556,6 +557,7 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
display_field='display_name',
|
||||
brief_mode=False,
|
||||
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['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
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):
|
||||
class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all()
|
||||
)
|
||||
|
@ -5,7 +5,6 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import BaseInterface, Device
|
||||
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
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
|
||||
# of the uniqueness constraint without manual intervention.
|
||||
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({
|
||||
'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)
|
||||
@ -449,8 +448,8 @@ class VMInterface(BaseInterface):
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
|
||||
"virtual machine, or it must be global".format(self.untagged_vlan)
|
||||
'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
|
||||
f"interface's parent virtual machine, or it must be global"
|
||||
})
|
||||
|
||||
def to_objectchange(self, action):
|
||||
|
@ -183,3 +183,6 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.contrib import messages
|
||||
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.urls import reverse
|
||||
|
||||
@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from netbox.views import generic
|
||||
from secrets.models import Secret
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.utils import count_related
|
||||
from . import filters, forms, tables
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
@ -21,7 +21,9 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -41,7 +43,9 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -50,7 +54,9 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -70,7 +76,9 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -81,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
class ClusterListView(generic.ObjectListView):
|
||||
permission_required = 'virtualization.view_cluster'
|
||||
queryset = Cluster.objects.annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
)
|
||||
table = tables.ClusterTable
|
||||
filterset = filters.ClusterFilterSet
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user