mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Merge branch 'feature' into oob_ip
This commit is contained in:
commit
b92360faee
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.4
|
placeholder: v3.5.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.4
|
placeholder: v3.5.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
14
README.md
14
README.md
@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
|
|||||||
## Project Stats
|
## Project Stats
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
|
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
|
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
|
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
|
||||||
<br />Stats via <a href="https://repography.com">Repography</a>
|
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -66,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
|
|||||||
[](https://netboxlabs.com)
|
[](https://netboxlabs.com)
|
||||||
|
|
||||||
[](https://try.digitalocean.com/developer-cloud)
|
[](https://try.digitalocean.com/developer-cloud)
|
||||||
<br />
|
|
||||||
[](https://sentry.io)
|
|
||||||
|
|
||||||
|
[](https://sentry.io)
|
||||||
|
<br />
|
||||||
[](https://metal.equinix.com)
|
[](https://metal.equinix.com)
|
||||||
|
|
||||||
|
[](https://onemindservices.com)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ boto3
|
|||||||
|
|
||||||
# The Python web framework on which NetBox is built
|
# The Python web framework on which NetBox is built
|
||||||
# https://docs.djangoproject.com/en/stable/releases/
|
# https://docs.djangoproject.com/en/stable/releases/
|
||||||
Django<4.2
|
Django<5.0
|
||||||
|
|
||||||
# Django middleware which permits cross-domain API requests
|
# Django middleware which permits cross-domain API requests
|
||||||
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
|
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
|
||||||
@ -121,8 +121,8 @@ netaddr
|
|||||||
Pillow
|
Pillow
|
||||||
|
|
||||||
# PostgreSQL database adapter for Python
|
# PostgreSQL database adapter for Python
|
||||||
# https://www.psycopg.org/docs/news.html
|
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
|
||||||
psycopg2-binary
|
psycopg[binary,pool]
|
||||||
|
|
||||||
# YAML rendering library
|
# YAML rendering library
|
||||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||||
|
@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
|
|||||||
|
|
||||||
## DATABASE
|
## DATABASE
|
||||||
|
|
||||||
NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
|
||||||
|
|
||||||
* `NAME` - Database name
|
* `NAME` - Database name
|
||||||
* `USER` - PostgreSQL username
|
* `USER` - PostgreSQL username
|
||||||
|
@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
|
|||||||
|
|
||||||
### Custom Selection Fields
|
### Custom Selection Fields
|
||||||
|
|
||||||
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
|
Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
|
||||||
|
|
||||||
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
|
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
|
||||||
|
|
||||||
|
@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav
|
|||||||
GET /api/dcim/devices/?tag=monitored&tag=deprecated
|
GET /api/dcim/devices/?tag=monitored&tag=deprecated
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Bookmarks
|
||||||
|
|
||||||
|
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
|
||||||
|
|
||||||
## Custom Fields
|
## Custom Fields
|
||||||
|
|
||||||
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.
|
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
||||||
|
|
||||||
!!! warning "PostgreSQL 11 or later required"
|
!!! warning "PostgreSQL 12 or later required"
|
||||||
NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported.
|
NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da
|
|||||||
sudo systemctl enable postgresql
|
sudo systemctl enable postgresql
|
||||||
```
|
```
|
||||||
|
|
||||||
Before continuing, verify that you have installed PostgreSQL 11 or later:
|
Before continuing, verify that you have installed PostgreSQL 12 or later:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
psql -V
|
psql -V
|
||||||
|
@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
|
|||||||
| Dependency | Minimum Version |
|
| Dependency | Minimum Version |
|
||||||
|------------|-----------------|
|
|------------|-----------------|
|
||||||
| Python | 3.8 |
|
| Python | 3.8 |
|
||||||
| PostgreSQL | 11 |
|
| PostgreSQL | 12 |
|
||||||
| Redis | 4.0 |
|
| Redis | 4.0 |
|
||||||
|
|
||||||
Below is a simplified overview of the NetBox application stack for reference:
|
Below is a simplified overview of the NetBox application stack for reference:
|
||||||
|
@ -15,12 +15,12 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
|
|||||||
|
|
||||||
## 2. Update Dependencies to Required Versions
|
## 2. Update Dependencies to Required Versions
|
||||||
|
|
||||||
NetBox v3.0 and later require the following:
|
NetBox requires the following dependencies:
|
||||||
|
|
||||||
| Dependency | Minimum Version |
|
| Dependency | Minimum Version |
|
||||||
|------------|-----------------|
|
|------------|-----------------|
|
||||||
| Python | 3.8 |
|
| Python | 3.8 |
|
||||||
| PostgreSQL | 11 |
|
| PostgreSQL | 12 |
|
||||||
| Redis | 4.0 |
|
| Redis | 4.0 |
|
||||||
|
|
||||||
## 3. Install the Latest Release
|
## 3. Install the Latest Release
|
||||||
|
@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
|||||||
| HTTP service | nginx or Apache |
|
| HTTP service | nginx or Apache |
|
||||||
| WSGI service | gunicorn or uWSGI |
|
| WSGI service | gunicorn or uWSGI |
|
||||||
| Application | Django/Python |
|
| Application | Django/Python |
|
||||||
| Database | PostgreSQL 11+ |
|
| Database | PostgreSQL 12+ |
|
||||||
| Task queuing | Redis/django-rq |
|
| Task queuing | Redis/django-rq |
|
||||||
|
@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
|
|||||||
!!! tip
|
!!! tip
|
||||||
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
|
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
|
||||||
|
|
||||||
|
### Latitude & Longitude
|
||||||
|
|
||||||
|
GPS coordinates of the device for geolocation.
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
The device's operational status.
|
The device's operational status.
|
||||||
|
13
docs/models/extras/bookmark.md
Normal file
13
docs/models/extras/bookmark.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Bookmarks
|
||||||
|
|
||||||
|
A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
The user to whom the bookmark belongs.
|
||||||
|
|
||||||
|
### Object
|
||||||
|
|
||||||
|
The bookmarked object.
|
@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
|
|||||||
|
|
||||||
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
|
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
|
||||||
|
|
||||||
### Choices
|
### Choice Set
|
||||||
|
|
||||||
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
|
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
|
||||||
|
|
||||||
### Cloneable
|
### Cloneable
|
||||||
|
|
||||||
|
17
docs/models/extras/customfieldchoiceset.md
Normal file
17
docs/models/extras/customfieldchoiceset.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Custom Field Choice Sets
|
||||||
|
|
||||||
|
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
### Name
|
||||||
|
|
||||||
|
The human-friendly name of the choice set.
|
||||||
|
|
||||||
|
### Extra Choices
|
||||||
|
|
||||||
|
The list of valid choices, entered as a comma-separated list.
|
||||||
|
|
||||||
|
### Order Alphabetically
|
||||||
|
|
||||||
|
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.
|
@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
|
|||||||
### Color
|
### Color
|
||||||
|
|
||||||
The color to use when displaying the tag in the NetBox UI.
|
The color to use when displaying the tag in the NetBox UI.
|
||||||
|
|
||||||
|
### Object Types
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v3.6."
|
||||||
|
|
||||||
|
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
|
||||||
|
|
||||||
|
If no object types are specified, the tag will be assignable to any type of object.
|
||||||
|
@ -165,19 +165,6 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
|
|||||||
options:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## Choice Fields
|
|
||||||
|
|
||||||
!!! warning "Obsolete Fields"
|
|
||||||
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
|
|
||||||
|
|
||||||
::: utilities.forms.fields.ChoiceField
|
|
||||||
options:
|
|
||||||
members: false
|
|
||||||
|
|
||||||
::: utilities.forms.fields.MultipleChoiceField
|
|
||||||
options:
|
|
||||||
members: false
|
|
||||||
|
|
||||||
## Dynamic Object Fields
|
## Dynamic Object Fields
|
||||||
|
|
||||||
::: utilities.forms.fields.DynamicModelChoiceField
|
::: utilities.forms.fields.DynamicModelChoiceField
|
||||||
|
@ -19,6 +19,9 @@ class MyModel(models.Model):
|
|||||||
|
|
||||||
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
|
||||||
|
|
||||||
## Enabling NetBox Features
|
## Enabling NetBox Features
|
||||||
|
|
||||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||||
|
@ -1,15 +1,53 @@
|
|||||||
# NetBox v3.5
|
# NetBox v3.5
|
||||||
|
|
||||||
## v3.5.5 (FUTURE)
|
## v3.5.7 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.5.6 (2023-07-10)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
|
||||||
|
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
|
||||||
|
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
|
||||||
|
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.5.5 (2023-07-06)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
|
||||||
|
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
|
||||||
|
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
|
||||||
|
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
|
||||||
|
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
|
||||||
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
|
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
|
||||||
|
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
|
||||||
|
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
|
||||||
|
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
|
||||||
|
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
|
||||||
|
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
|
||||||
|
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
|
||||||
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
|
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
|
||||||
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
|
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
|
||||||
|
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
|
||||||
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
|
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
|
||||||
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
|
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
|
||||||
|
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
|
||||||
|
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
|
||||||
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
|
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
|
||||||
|
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
|
||||||
|
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
|
||||||
|
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
|
||||||
|
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
|
||||||
|
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
22
docs/release-notes/version-3.6.md
Normal file
22
docs/release-notes/version-3.6.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# NetBox v3.6
|
||||||
|
|
||||||
|
## v3.6.0 (FUTURE)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
|
||||||
|
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model.
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
|
||||||
|
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
|
||||||
|
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
|
||||||
|
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
|
||||||
|
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
|
||||||
|
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||||
|
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
|
||||||
|
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL
|
@ -206,6 +206,7 @@ nav:
|
|||||||
- VirtualChassis: 'models/dcim/virtualchassis.md'
|
- VirtualChassis: 'models/dcim/virtualchassis.md'
|
||||||
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
|
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
|
||||||
- Extras:
|
- Extras:
|
||||||
|
- Bookmark: 'models/extras/bookmark.md'
|
||||||
- Branch: 'models/extras/branch.md'
|
- Branch: 'models/extras/branch.md'
|
||||||
- ConfigContext: 'models/extras/configcontext.md'
|
- ConfigContext: 'models/extras/configcontext.md'
|
||||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||||
@ -273,6 +274,7 @@ nav:
|
|||||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||||
- Release Notes:
|
- Release Notes:
|
||||||
- Summary: 'release-notes/index.md'
|
- Summary: 'release-notes/index.md'
|
||||||
|
- Version 3.6: 'release-notes/version-3.6.md'
|
||||||
- Version 3.5: 'release-notes/version-3.5.md'
|
- Version 3.5: 'release-notes/version-3.5.md'
|
||||||
- Version 3.4: 'release-notes/version-3.4.md'
|
- Version 3.4: 'release-notes/version-3.4.md'
|
||||||
- Version 3.3: 'release-notes/version-3.3.md'
|
- Version 3.3: 'release-notes/version-3.3.md'
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
widget=DateTimePicker()
|
widget=DateTimePicker()
|
||||||
)
|
)
|
||||||
user = DynamicModelMultipleChoiceField(
|
user = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('User'),
|
label=_('User'),
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
|
@ -5,7 +5,7 @@ import sys
|
|||||||
from django import get_version
|
from django import get_version
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Additional objects to include
|
# Additional objects to include
|
||||||
namespace['ContentType'] = ContentType
|
namespace['ContentType'] = ContentType
|
||||||
namespace['User'] = User
|
namespace['User'] = get_user_model()
|
||||||
|
|
||||||
# Load convenience commands
|
# Load convenience commands
|
||||||
namespace.update({
|
namespace.update({
|
||||||
|
@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
|
|
||||||
# Emit the post_sync signal
|
# Emit the post_sync signal
|
||||||
post_sync.send(sender=self.__class__, instance=self)
|
post_sync.send(sender=self.__class__, instance=self)
|
||||||
|
sync.alters_data = True
|
||||||
|
|
||||||
def _walk(self, root):
|
def _walk(self, root):
|
||||||
"""
|
"""
|
||||||
@ -289,8 +290,10 @@ class DataFile(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def data_as_string(self):
|
def data_as_string(self):
|
||||||
|
if not self.data:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return self.data.tobytes().decode('utf-8')
|
return bytes(self.data, 'utf-8')
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
from django.contrib.auth.models import User
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
@ -69,7 +69,7 @@ class Job(models.Model):
|
|||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -635,8 +635,8 @@ class PlatformSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
|
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -676,10 +676,10 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
|
||||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||||
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
|
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
|
||||||
'oob_ip', 'oob_ip4', 'oob_ip6',
|
'oob_ip', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(NestedDeviceSerializer)
|
@extend_schema_field(NestedDeviceSerializer)
|
||||||
@ -702,8 +702,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
|||||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'oob_ip',
|
||||||
'oob_ip', 'oob_ip4', 'oob_ip6'
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
|
@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_100GE_CXP = '100gbase-x-cxp'
|
TYPE_100GE_CXP = '100gbase-x-cxp'
|
||||||
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
||||||
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
||||||
|
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
|
||||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||||
@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||||
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
||||||
|
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||||
|
@ -17,6 +17,8 @@ RACK_ELEVATION_BORDER_WIDTH = 2
|
|||||||
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
||||||
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
||||||
|
|
||||||
|
RACK_STARTING_UNIT_DEFAULT = 1
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# RearPorts
|
# RearPorts
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
label=_('Location (slug)'),
|
label=_('Location (slug)'),
|
||||||
)
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
)
|
)
|
||||||
user = django_filters.ModelMultipleChoiceFilter(
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='user__username',
|
field_name='user__username',
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
to_field_name='username',
|
to_field_name='username',
|
||||||
label=_('User (name)'),
|
label=_('User (name)'),
|
||||||
)
|
)
|
||||||
@ -811,7 +811,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
|
fields = ['id', 'name', 'slug', 'description']
|
||||||
|
|
||||||
|
|
||||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||||
@ -1013,7 +1013,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
|
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@ -1097,10 +1097,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
|
||||||
Q(name__icontains=value) |
|
qs_filter = Q(name__icontains=value)
|
||||||
Q(identifier=value.strip())
|
try:
|
||||||
).distinct()
|
qs_filter |= Q(identifier=int(value))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return queryset.filter(qs_filter).distinct()
|
||||||
|
|
||||||
def _has_primary_ip(self, queryset, name, value):
|
def _has_primary_ip(self, queryset, name, value):
|
||||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from timezone_field import TimeZoneFormField
|
from timezone_field import TimeZoneFormField
|
||||||
|
|
||||||
@ -322,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
user = forms.ModelChoiceField(
|
user = forms.ModelChoiceField(
|
||||||
queryset=User.objects.order_by(
|
queryset=get_user_model().objects.order_by(
|
||||||
'username'
|
'username'
|
||||||
),
|
),
|
||||||
required=False
|
required=False
|
||||||
@ -472,10 +472,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
napalm_driver = forms.CharField(
|
|
||||||
max_length=50,
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
config_template = DynamicModelChoiceField(
|
config_template = DynamicModelChoiceField(
|
||||||
queryset=ConfigTemplate.objects.all(),
|
queryset=ConfigTemplate.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -487,9 +483,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = Platform
|
model = Platform
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
|
(None, ('manufacturer', 'config_template', 'description')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
|
nullable_fields = ('manufacturer', 'config_template', 'description')
|
||||||
|
|
||||||
|
|
||||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
@ -1106,7 +1102,7 @@ class PowerPortBulkEditForm(
|
|||||||
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
|
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
|
||||||
('Power', ('maximum_draw', 'allocated_draw')),
|
('Power', ('maximum_draw', 'allocated_draw')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('module', 'label', 'description')
|
nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletBulkEditForm(
|
class PowerOutletBulkEditForm(
|
||||||
|
@ -365,7 +365,7 @@ class PlatformImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
class Meta(BaseDeviceImportForm.Meta):
|
class Meta(BaseDeviceImportForm.Meta):
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
|
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
|
||||||
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
|
'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
@ -376,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
label=_('Rack')
|
label=_('Rack')
|
||||||
)
|
)
|
||||||
user_id = DynamicModelMultipleChoiceField(
|
user_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('User'),
|
label=_('User'),
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from timezone_field import TimeZoneFormField
|
from timezone_field import TimeZoneFormField
|
||||||
@ -221,8 +221,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
|||||||
model = Rack
|
model = Rack
|
||||||
fields = [
|
fields = [
|
||||||
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
|
||||||
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
|||||||
help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
|
help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
|
||||||
)
|
)
|
||||||
user = forms.ModelChoiceField(
|
user = forms.ModelChoiceField(
|
||||||
queryset=User.objects.order_by(
|
queryset=get_user_model().objects.order_by(
|
||||||
'username'
|
'username'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -360,19 +360,14 @@ class PlatformForm(NetBoxModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Platform', (
|
('Platform', ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
|
||||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
|
||||||
'napalm_args': forms.Textarea(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(TenancyForm, NetBoxModelForm):
|
class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||||
@ -454,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
|
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
|
||||||
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
|
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
|
||||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
|
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
|
||||||
'local_context_data', 'oob_ip4', 'oob_ip6'
|
'comments', 'tags', 'local_context_data' 'oob_ip',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
19
netbox/dcim/migrations/0173_remove_napalm_fields.py
Normal file
19
netbox/dcim/migrations/0173_remove_napalm_fields.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0172_larger_power_draw_values'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='platform',
|
||||||
|
name='napalm_args',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='platform',
|
||||||
|
name='napalm_driver',
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-31 22:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0173_remove_napalm_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='latitude',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='longitude',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||||
|
),
|
||||||
|
]
|
17
netbox/dcim/migrations/0174_rack_starting_unit.py
Normal file
17
netbox/dcim/migrations/0174_rack_starting_unit.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0174_device_latitude_device_longitude'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rack',
|
||||||
|
name='starting_unit',
|
||||||
|
field=models.PositiveSmallIntegerField(default=1),
|
||||||
|
),
|
||||||
|
]
|
@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
# Circuit terminations
|
# Circuit terminations
|
||||||
elif getattr(self.termination, 'site', None):
|
elif getattr(self.termination, 'site', None):
|
||||||
self._site = self.termination.site
|
self._site = self.termination.site
|
||||||
|
cache_related_objects.alters_data = True
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(action)
|
objectchange = super().to_objectchange(action)
|
||||||
@ -637,6 +638,7 @@ class CablePath(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
else:
|
else:
|
||||||
self.delete()
|
self.delete()
|
||||||
|
retrace.alters_data = True
|
||||||
|
|
||||||
def _get_path(self):
|
def _get_path(self):
|
||||||
"""
|
"""
|
||||||
|
@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
|||||||
type=self.type,
|
type=self.type,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
|||||||
allocated_draw=self.allocated_draw,
|
allocated_draw=self.allocated_draw,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
|||||||
feed_leg=self.feed_leg,
|
feed_leg=self.feed_leg,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
|||||||
poe_type=self.poe_type,
|
poe_type=self.poe_type,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
|||||||
rear_port_position=self.rear_port_position,
|
rear_port_position=self.rear_port_position,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
|||||||
positions=self.positions,
|
positions=self.positions,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
|||||||
label=self.label,
|
label=self.label,
|
||||||
position=self.position
|
position=self.position
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
label=self.label
|
label=self.label
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
||||||
@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
|||||||
part_id=self.part_id,
|
part_id=self.part_id,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
@ -432,9 +432,8 @@ class DeviceRole(OrganizationalModel):
|
|||||||
|
|
||||||
class Platform(OrganizationalModel):
|
class Platform(OrganizationalModel):
|
||||||
"""
|
"""
|
||||||
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
|
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
|
||||||
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
Platform may optionally be associated with a particular Manufacturer.
|
||||||
specifying a NAPALM driver.
|
|
||||||
"""
|
"""
|
||||||
manufacturer = models.ForeignKey(
|
manufacturer = models.ForeignKey(
|
||||||
to='dcim.Manufacturer',
|
to='dcim.Manufacturer',
|
||||||
@ -451,18 +450,6 @@ class Platform(OrganizationalModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
napalm_driver = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
blank=True,
|
|
||||||
verbose_name='NAPALM driver',
|
|
||||||
help_text=_('The name of the NAPALM driver to use when interacting with devices')
|
|
||||||
)
|
|
||||||
napalm_args = models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name='NAPALM arguments',
|
|
||||||
help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('dcim:platform', args=[self.pk])
|
return reverse('dcim:platform', args=[self.pk])
|
||||||
@ -653,6 +640,20 @@ class Device(PrimaryModel, ConfigContextModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
latitude = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=6,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
|
)
|
||||||
|
longitude = models.DecimalField(
|
||||||
|
max_digits=9,
|
||||||
|
decimal_places=6,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
|
)
|
||||||
|
|
||||||
# Generic relations
|
# Generic relations
|
||||||
contacts = GenericRelation(
|
contacts = GenericRelation(
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import decimal
|
import decimal
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -129,6 +129,11 @@ class Rack(PrimaryModel, WeightMixin):
|
|||||||
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
|
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
|
||||||
help_text=_('Height in rack units')
|
help_text=_('Height in rack units')
|
||||||
)
|
)
|
||||||
|
starting_unit = models.PositiveSmallIntegerField(
|
||||||
|
default=RACK_STARTING_UNIT_DEFAULT,
|
||||||
|
verbose_name='Starting unit',
|
||||||
|
help_text=_('Starting unit for rack')
|
||||||
|
)
|
||||||
desc_units = models.BooleanField(
|
desc_units = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name='Descending units',
|
verbose_name='Descending units',
|
||||||
@ -228,20 +233,24 @@ class Rack(PrimaryModel, WeightMixin):
|
|||||||
raise ValidationError("Must specify a unit when setting a maximum weight")
|
raise ValidationError("Must specify a unit when setting a maximum weight")
|
||||||
|
|
||||||
if self.pk:
|
if self.pk:
|
||||||
# Validate that Rack is tall enough to house the installed Devices
|
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
|
||||||
top_device = Device.objects.filter(
|
|
||||||
rack=self
|
# Validate that Rack is tall enough to house the highest mounted Device
|
||||||
).exclude(
|
if top_device := mounted_devices.last():
|
||||||
position__isnull=True
|
min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
|
||||||
).order_by('-position').first()
|
|
||||||
if top_device:
|
|
||||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
|
||||||
if self.u_height < min_height:
|
if self.u_height < min_height:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
|
'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices."
|
||||||
min_height
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
|
||||||
|
if last_device := mounted_devices.first():
|
||||||
|
if self.starting_unit > last_device.position:
|
||||||
|
raise ValidationError({
|
||||||
|
'starting_unit': f"Rack unit numbering must begin at {last_device.position} or less to house "
|
||||||
|
f"currently installed devices."
|
||||||
|
})
|
||||||
|
|
||||||
# Validate that Rack was assigned a Location of its same site, if applicable
|
# Validate that Rack was assigned a Location of its same site, if applicable
|
||||||
if self.location:
|
if self.location:
|
||||||
if self.location.site != self.site:
|
if self.location.site != self.site:
|
||||||
@ -269,8 +278,8 @@ class Rack(PrimaryModel, WeightMixin):
|
|||||||
Return a list of unit numbers, top to bottom.
|
Return a list of unit numbers, top to bottom.
|
||||||
"""
|
"""
|
||||||
if self.desc_units:
|
if self.desc_units:
|
||||||
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
|
return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
|
||||||
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
|
return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
|
||||||
|
|
||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return RackStatusChoices.colors.get(self.status)
|
return RackStatusChoices.colors.get(self.status)
|
||||||
@ -505,7 +514,7 @@ class RackReservation(PrimaryModel):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.PROTECT
|
on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
|
@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
|
|||||||
fields = (
|
fields = (
|
||||||
('name', 100),
|
('name', 100),
|
||||||
('slug', 110),
|
('slug', 110),
|
||||||
('napalm_driver', 300),
|
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -150,9 +150,9 @@ class RackElevationSVG:
|
|||||||
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
|
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
|
||||||
y = RACK_ELEVATION_BORDER_WIDTH
|
y = RACK_ELEVATION_BORDER_WIDTH
|
||||||
if self.rack.desc_units:
|
if self.rack.desc_units:
|
||||||
y += int((position - 1) * self.unit_height)
|
y += int((position - self.rack.starting_unit) * self.unit_height)
|
||||||
else:
|
else:
|
||||||
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
|
y += int((self.rack.u_height - position + self.rack.starting_unit) * self.unit_height) - int(height * self.unit_height)
|
||||||
|
|
||||||
return x, y
|
return x, y
|
||||||
|
|
||||||
@ -237,6 +237,7 @@ class RackElevationSVG:
|
|||||||
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
|
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||||
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
||||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||||
|
unit = unit + self.rack.starting_unit - 1
|
||||||
self.drawing.add(
|
self.drawing.add(
|
||||||
Text(str(unit), position_coordinates, class_='unit')
|
Text(str(unit), position_coordinates, class_='unit')
|
||||||
)
|
)
|
||||||
@ -278,6 +279,7 @@ class RackElevationSVG:
|
|||||||
|
|
||||||
for ru in range(0, self.rack.u_height):
|
for ru in range(0, self.rack.u_height):
|
||||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||||
|
unit = unit + self.rack.starting_unit - 1
|
||||||
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
|
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
|
||||||
text_coords = (
|
text_coords = (
|
||||||
x_offset + self.unit_width / 2,
|
x_offset + self.unit_width / 2,
|
||||||
|
@ -137,11 +137,11 @@ class PlatformTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.Platform
|
model = models.Platform
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
|
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
|
||||||
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
|
'tags', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
|
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -249,9 +249,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||||
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
|
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
|
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
|
||||||
'tags', 'created', 'last_updated', 'oob_ip', 'oob_ip4', 'oob_ip6',
|
'comments', 'contacts', 'oob_ip', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices
|
|||||||
from wireless.models import WirelessLAN
|
from wireless.models import WirelessLAN
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class AppTest(APITestCase):
|
class AppTest(APITestCase):
|
||||||
|
|
||||||
def test_root(self):
|
def test_root(self):
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
@ -12,6 +12,9 @@ from virtualization.models import Cluster, ClusterType
|
|||||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class DeviceComponentFilterSetTests:
|
class DeviceComponentFilterSetTests:
|
||||||
|
|
||||||
def test_device_type(self):
|
def test_device_type(self):
|
||||||
@ -1515,9 +1518,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Manufacturer.objects.bulk_create(manufacturers)
|
Manufacturer.objects.bulk_create(manufacturers)
|
||||||
|
|
||||||
platforms = (
|
platforms = (
|
||||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'),
|
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
|
||||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'),
|
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
|
||||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'),
|
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
|
||||||
)
|
)
|
||||||
Platform.objects.bulk_create(platforms)
|
Platform.objects.bulk_create(platforms)
|
||||||
|
|
||||||
@ -1533,10 +1536,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'description': ['A', 'B']}
|
params = {'description': ['A', 'B']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_napalm_driver(self):
|
|
||||||
params = {'napalm_driver': ['driver-1', 'driver-2']}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
|
||||||
|
|
||||||
def test_manufacturer(self):
|
def test_manufacturer(self):
|
||||||
manufacturers = Manufacturer.objects.all()[:2]
|
manufacturers = Manufacturer.objects.all()[:2]
|
||||||
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
|
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
|
||||||
@ -1642,9 +1641,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Tenant.objects.bulk_create(tenants)
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
|
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
|
||||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
|
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
|
||||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
|
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
|
||||||
)
|
)
|
||||||
Device.objects.bulk_create(devices)
|
Device.objects.bulk_create(devices)
|
||||||
|
|
||||||
@ -1725,6 +1724,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'position': [1, 2]}
|
params = {'position': [1, 2]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_latitude(self):
|
||||||
|
params = {'latitude': [10, 20]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_longitude(self):
|
||||||
|
params = {'longitude': [10, 20]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_vc_position(self):
|
def test_vc_position(self):
|
||||||
params = {'vc_position': [1, 2]}
|
params = {'vc_position': [1, 2]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -6,7 +6,7 @@ except ImportError:
|
|||||||
from backports.zoneinfo import ZoneInfo
|
from backports.zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -22,6 +22,9 @@ from utilities.testing import ViewTestCases, create_tags, create_test_device, po
|
|||||||
from wireless.models import WirelessLAN
|
from wireless.models import WirelessLAN
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||||
model = Region
|
model = Region
|
||||||
|
|
||||||
@ -389,6 +392,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'outer_width': 500,
|
'outer_width': 500,
|
||||||
'outer_depth': 500,
|
'outer_depth': 500,
|
||||||
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
|
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
|
||||||
|
'starting_unit': 1,
|
||||||
'weight': 100,
|
'weight': 100,
|
||||||
'max_weight': 2000,
|
'max_weight': 2000,
|
||||||
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
'weight_unit': WeightUnitChoices.UNIT_POUND,
|
||||||
@ -1609,8 +1613,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
'name': 'Platform X',
|
'name': 'Platform X',
|
||||||
'slug': 'platform-x',
|
'slug': 'platform-x',
|
||||||
'manufacturer': manufacturer.pk,
|
'manufacturer': manufacturer.pk,
|
||||||
'napalm_driver': 'junos',
|
|
||||||
'napalm_args': None,
|
|
||||||
'description': 'A new platform',
|
'description': 'A new platform',
|
||||||
'tags': [t.pk for t in tags],
|
'tags': [t.pk for t in tags],
|
||||||
}
|
}
|
||||||
@ -1630,7 +1632,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
'napalm_driver': 'ios',
|
|
||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1699,6 +1700,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'rack': racks[1].pk,
|
'rack': racks[1].pk,
|
||||||
'position': 1,
|
'position': 1,
|
||||||
'face': DeviceFaceChoices.FACE_FRONT,
|
'face': DeviceFaceChoices.FACE_FRONT,
|
||||||
|
'latitude': Decimal('35.780000'),
|
||||||
|
'longitude': Decimal('-78.642000'),
|
||||||
'status': DeviceStatusChoices.STATUS_PLANNED,
|
'status': DeviceStatusChoices.STATUS_PLANNED,
|
||||||
'primary_ip4': None,
|
'primary_ip4': None,
|
||||||
'primary_ip6': None,
|
'primary_ip6': None,
|
||||||
|
@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView):
|
|||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
def get_extra_addanother_params(self, request):
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'a_terminations_type': request.GET.get('a_terminations_type'),
|
||||||
|
'b_terminations_type': request.GET.get('b_terminations_type')
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in request.POST:
|
||||||
|
if 'device' in key or 'power_panel' in key or 'circuit' in key:
|
||||||
|
params.update({key: request.POST.get(key)})
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Cable, 'delete')
|
@register_model_view(Cable, 'delete')
|
||||||
class CableDeleteView(generic.ObjectDeleteView):
|
class CableDeleteView(generic.ObjectDeleteView):
|
||||||
|
@ -1,129 +1,2 @@
|
|||||||
from django.contrib import admin
|
# TODO: Removing this import triggers an import loop due to how form mixins are currently organized
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
|
||||||
from django.template.response import TemplateResponse
|
|
||||||
from django.urls import path, reverse
|
|
||||||
from django.utils.html import format_html
|
|
||||||
|
|
||||||
from netbox.config import get_config, PARAMS
|
|
||||||
from .forms import ConfigRevisionForm
|
from .forms import ConfigRevisionForm
|
||||||
from .models import ConfigRevision
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ConfigRevision)
|
|
||||||
class ConfigRevisionAdmin(admin.ModelAdmin):
|
|
||||||
fieldsets = [
|
|
||||||
('Rack Elevations', {
|
|
||||||
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
|
|
||||||
}),
|
|
||||||
('Power', {
|
|
||||||
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
|
|
||||||
}),
|
|
||||||
('IPAM', {
|
|
||||||
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
|
|
||||||
}),
|
|
||||||
('Security', {
|
|
||||||
'fields': ('ALLOWED_URL_SCHEMES',),
|
|
||||||
}),
|
|
||||||
('Banners', {
|
|
||||||
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
|
||||||
'classes': ('monospace',),
|
|
||||||
}),
|
|
||||||
('Pagination', {
|
|
||||||
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
|
|
||||||
}),
|
|
||||||
('Validation', {
|
|
||||||
'fields': ('CUSTOM_VALIDATORS',),
|
|
||||||
'classes': ('monospace',),
|
|
||||||
}),
|
|
||||||
('User Preferences', {
|
|
||||||
'fields': ('DEFAULT_USER_PREFERENCES',),
|
|
||||||
}),
|
|
||||||
('Miscellaneous', {
|
|
||||||
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'),
|
|
||||||
}),
|
|
||||||
('Config Revision', {
|
|
||||||
'fields': ('comment',),
|
|
||||||
})
|
|
||||||
]
|
|
||||||
form = ConfigRevisionForm
|
|
||||||
list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
|
|
||||||
ordering = ('-id',)
|
|
||||||
readonly_fields = ('data',)
|
|
||||||
|
|
||||||
def get_changeform_initial_data(self, request):
|
|
||||||
"""
|
|
||||||
Populate initial form data from the most recent ConfigRevision.
|
|
||||||
"""
|
|
||||||
latest_revision = ConfigRevision.objects.last()
|
|
||||||
initial = latest_revision.data if latest_revision else {}
|
|
||||||
initial.update(super().get_changeform_initial_data(request))
|
|
||||||
|
|
||||||
return initial
|
|
||||||
|
|
||||||
# Permissions
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
# Only superusers may modify the configuration.
|
|
||||||
return request.user.is_superuser
|
|
||||||
|
|
||||||
def has_change_permission(self, request, obj=None):
|
|
||||||
# ConfigRevisions cannot be modified once created.
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
|
||||||
# Only inactive ConfigRevisions may be deleted (must be superuser).
|
|
||||||
return request.user.is_superuser and (
|
|
||||||
obj is None or not obj.is_active()
|
|
||||||
)
|
|
||||||
|
|
||||||
# List display methods
|
|
||||||
|
|
||||||
def restore_link(self, obj):
|
|
||||||
if obj.is_active():
|
|
||||||
return ''
|
|
||||||
return format_html(
|
|
||||||
'<a href="{url}" class="button">Restore</a>',
|
|
||||||
url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
|
|
||||||
)
|
|
||||||
restore_link.short_description = "Actions"
|
|
||||||
|
|
||||||
# URLs
|
|
||||||
|
|
||||||
def get_urls(self):
|
|
||||||
urls = [
|
|
||||||
path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
|
|
||||||
]
|
|
||||||
|
|
||||||
return urls + super().get_urls()
|
|
||||||
|
|
||||||
# Views
|
|
||||||
|
|
||||||
def restore(self, request, pk):
|
|
||||||
# Get the ConfigRevision being restored
|
|
||||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
candidate_config.activate()
|
|
||||||
self.message_user(request, f"Restored configuration revision #{pk}")
|
|
||||||
|
|
||||||
return redirect(reverse('admin:extras_configrevision_changelist'))
|
|
||||||
|
|
||||||
# Get the current ConfigRevision
|
|
||||||
config_version = get_config().version
|
|
||||||
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
|
||||||
|
|
||||||
params = []
|
|
||||||
for param in PARAMS:
|
|
||||||
params.append((
|
|
||||||
param.name,
|
|
||||||
current_config.data.get(param.name, None),
|
|
||||||
candidate_config.data.get(param.name, None)
|
|
||||||
))
|
|
||||||
|
|
||||||
context = self.admin_site.each_context(request)
|
|
||||||
context.update({
|
|
||||||
'object': candidate_config,
|
|
||||||
'params': params,
|
|
||||||
})
|
|
||||||
|
|
||||||
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
|
||||||
|
@ -4,8 +4,10 @@ from extras import models
|
|||||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'NestedBookmarkSerializer',
|
||||||
'NestedConfigContextSerializer',
|
'NestedConfigContextSerializer',
|
||||||
'NestedConfigTemplateSerializer',
|
'NestedConfigTemplateSerializer',
|
||||||
|
'NestedCustomFieldChoiceSetSerializer',
|
||||||
'NestedCustomFieldSerializer',
|
'NestedCustomFieldSerializer',
|
||||||
'NestedCustomLinkSerializer',
|
'NestedCustomLinkSerializer',
|
||||||
'NestedExportTemplateSerializer',
|
'NestedExportTemplateSerializer',
|
||||||
@ -33,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
|
|||||||
fields = ['id', 'url', 'display', 'name']
|
fields = ['id', 'url', 'display', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.CustomFieldChoiceSet
|
||||||
|
fields = ['id', 'url', 'display', 'name', 'choices_count']
|
||||||
|
|
||||||
|
|
||||||
class NestedCustomLinkSerializer(WritableNestedSerializer):
|
class NestedCustomLinkSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
|
||||||
|
|
||||||
@ -73,6 +83,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
|
|||||||
fields = ['id', 'url', 'display', 'name', 'slug']
|
fields = ['id', 'url', 'display', 'name', 'slug']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedBookmarkSerializer(WritableNestedSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Bookmark
|
||||||
|
fields = ['id', 'url', 'display', 'object_id', 'object_type']
|
||||||
|
|
||||||
|
|
||||||
class NestedImageAttachmentSerializer(WritableNestedSerializer):
|
class NestedImageAttachmentSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -31,9 +31,11 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
|||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkSerializer',
|
||||||
'ConfigContextSerializer',
|
'ConfigContextSerializer',
|
||||||
'ConfigTemplateSerializer',
|
'ConfigTemplateSerializer',
|
||||||
'ContentTypeSerializer',
|
'ContentTypeSerializer',
|
||||||
|
'CustomFieldChoiceSetSerializer',
|
||||||
'CustomFieldSerializer',
|
'CustomFieldSerializer',
|
||||||
'CustomLinkSerializer',
|
'CustomLinkSerializer',
|
||||||
'DashboardSerializer',
|
'DashboardSerializer',
|
||||||
@ -93,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
)
|
)
|
||||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||||
data_type = serializers.SerializerMethodField()
|
data_type = serializers.SerializerMethodField()
|
||||||
|
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
|
||||||
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
|
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -100,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||||
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
|
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
|
||||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
|
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
|
||||||
'last_updated',
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -126,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
return 'string'
|
return 'string'
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
|
||||||
|
'created', 'last_updated',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Custom links
|
# Custom links
|
||||||
#
|
#
|
||||||
@ -190,18 +204,48 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkSerializer(ValidatedModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
||||||
|
object_type = ContentTypeField(
|
||||||
|
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
|
||||||
|
)
|
||||||
|
object = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = NestedUserSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
|
||||||
|
]
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
|
def get_object(self, instance):
|
||||||
|
serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
|
||||||
|
return serializer(instance.object, context={'request': self.context['request']}).data
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
|
|
||||||
class TagSerializer(ValidatedModelSerializer):
|
class TagSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||||
|
object_types = ContentTypeField(
|
||||||
|
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||||
|
many=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
tagged_items = serializers.IntegerField(read_only=True)
|
tagged_items = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
|
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
|
||||||
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -256,7 +300,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
|||||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||||
created_by = serializers.PrimaryKeyRelatedField(
|
created_by = serializers.PrimaryKeyRelatedField(
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
default=serializers.CurrentUserDefault()
|
default=serializers.CurrentUserDefault()
|
||||||
)
|
)
|
||||||
|
@ -9,9 +9,11 @@ router.APIRootView = views.ExtrasRootView
|
|||||||
|
|
||||||
router.register('webhooks', views.WebhookViewSet)
|
router.register('webhooks', views.WebhookViewSet)
|
||||||
router.register('custom-fields', views.CustomFieldViewSet)
|
router.register('custom-fields', views.CustomFieldViewSet)
|
||||||
|
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
|
||||||
router.register('custom-links', views.CustomLinkViewSet)
|
router.register('custom-links', views.CustomLinkViewSet)
|
||||||
router.register('export-templates', views.ExportTemplateViewSet)
|
router.register('export-templates', views.ExportTemplateViewSet)
|
||||||
router.register('saved-filters', views.SavedFilterViewSet)
|
router.register('saved-filters', views.SavedFilterViewSet)
|
||||||
|
router.register('bookmarks', views.BookmarkViewSet)
|
||||||
router.register('tags', views.TagViewSet)
|
router.register('tags', views.TagViewSet)
|
||||||
router.register('image-attachments', views.ImageAttachmentViewSet)
|
router.register('image-attachments', views.ImageAttachmentViewSet)
|
||||||
router.register('journal-entries', views.JournalEntryViewSet)
|
router.register('journal-entries', views.JournalEntryViewSet)
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django_rq.queues import get_connection
|
from django_rq.queues import get_connection
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
class CustomFieldViewSet(NetBoxModelViewSet):
|
class CustomFieldViewSet(NetBoxModelViewSet):
|
||||||
metadata_class = ContentTypeMetadata
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
serializer_class = serializers.CustomFieldSerializer
|
serializer_class = serializers.CustomFieldSerializer
|
||||||
filterset_class = filtersets.CustomFieldFilterSet
|
filterset_class = filtersets.CustomFieldFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
serializer_class = serializers.CustomFieldChoiceSetSerializer
|
||||||
|
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Custom links
|
# Custom links
|
||||||
#
|
#
|
||||||
@ -93,6 +98,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.SavedFilterFilterSet
|
filterset_class = filtersets.SavedFilterFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkViewSet(NetBoxModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
|
queryset = Bookmark.objects.all()
|
||||||
|
serializer_class = serializers.BookmarkSerializer
|
||||||
|
filterset_class = filtersets.BookmarkFilterSet
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Tags
|
# Tags
|
||||||
#
|
#
|
||||||
@ -368,7 +384,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
Retrieve a list of recent changes.
|
Retrieve a list of recent changes.
|
||||||
"""
|
"""
|
||||||
metadata_class = ContentTypeMetadata
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ObjectChange.objects.prefetch_related('user')
|
queryset = ObjectChange.objects.valid_models().prefetch_related('user')
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
filterset_class = filtersets.ObjectChangeFilterSet
|
filterset_class = filtersets.ObjectChangeFilterSet
|
||||||
|
|
||||||
|
@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
|
|||||||
(LINK, 'Link'),
|
(LINK, 'Link'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkOrderingChoices(ChoiceSet):
|
||||||
|
|
||||||
|
ORDERING_NEWEST = '-created'
|
||||||
|
ORDERING_OLDEST = 'created'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(ORDERING_NEWEST, 'Newest'),
|
||||||
|
(ORDERING_OLDEST, 'Oldest'),
|
||||||
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
# ObjectChanges
|
# ObjectChanges
|
||||||
#
|
#
|
||||||
@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):
|
|||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Jounral entries
|
# Journal entries
|
||||||
#
|
#
|
||||||
|
|
||||||
class JournalEntryKindChoices(ChoiceSet):
|
class JournalEntryKindChoices(ChoiceSet):
|
||||||
|
@ -14,6 +14,7 @@ from django.template.loader import render_to_string
|
|||||||
from django.urls import NoReverseMatch, resolve, reverse
|
from django.urls import NoReverseMatch, resolve, reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from extras.choices import BookmarkOrderingChoices
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from utilities.forms import BootstrapMixin
|
from utilities.forms import BootstrapMixin
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
@ -22,6 +23,7 @@ from utilities.utils import content_type_identifier, content_type_name, dict_to_
|
|||||||
from .utils import register_widget
|
from .utils import register_widget
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarksWidget',
|
||||||
'DashboardWidget',
|
'DashboardWidget',
|
||||||
'NoteWidget',
|
'NoteWidget',
|
||||||
'ObjectCountsWidget',
|
'ObjectCountsWidget',
|
||||||
@ -316,3 +318,42 @@ class RSSFeedWidget(DashboardWidget):
|
|||||||
return {
|
return {
|
||||||
'feed': feed,
|
'feed': feed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_widget
|
||||||
|
class BookmarksWidget(DashboardWidget):
|
||||||
|
default_title = _('Bookmarks')
|
||||||
|
default_config = {
|
||||||
|
'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
|
||||||
|
}
|
||||||
|
description = _('Show your personal bookmarks')
|
||||||
|
template_name = 'extras/dashboard/widgets/bookmarks.html'
|
||||||
|
|
||||||
|
class ConfigForm(WidgetConfigForm):
|
||||||
|
object_types = forms.MultipleChoiceField(
|
||||||
|
# TODO: Restrict the choices by FeatureQuery('bookmarks')
|
||||||
|
choices=get_content_type_labels,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
order_by = forms.ChoiceField(
|
||||||
|
choices=BookmarkOrderingChoices
|
||||||
|
)
|
||||||
|
max_items = forms.IntegerField(
|
||||||
|
min_value=1,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, request):
|
||||||
|
from extras.models import Bookmark
|
||||||
|
|
||||||
|
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||||
|
if object_types := self.config.get('object_types'):
|
||||||
|
models = get_models_from_content_types(object_types)
|
||||||
|
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||||
|
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||||
|
if max_items := self.config.get('max_items'):
|
||||||
|
bookmarks = bookmarks[:max_items]
|
||||||
|
|
||||||
|
return render_to_string(self.template_name, {
|
||||||
|
'bookmarks': bookmarks,
|
||||||
|
})
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -15,9 +15,12 @@ from .filters import TagFilter
|
|||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkFilterSet',
|
||||||
'ConfigContextFilterSet',
|
'ConfigContextFilterSet',
|
||||||
|
'ConfigRevisionFilterSet',
|
||||||
'ConfigTemplateFilterSet',
|
'ConfigTemplateFilterSet',
|
||||||
'ContentTypeFilterSet',
|
'ContentTypeFilterSet',
|
||||||
|
'CustomFieldChoiceSetFilterSet',
|
||||||
'CustomFieldFilterSet',
|
'CustomFieldFilterSet',
|
||||||
'CustomLinkFilterSet',
|
'CustomLinkFilterSet',
|
||||||
'ExportTemplateFilterSet',
|
'ExportTemplateFilterSet',
|
||||||
@ -72,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
field_name='content_types__id'
|
field_name='content_types__id'
|
||||||
)
|
)
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
|
choice_set_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all()
|
||||||
|
)
|
||||||
|
choice_set = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='choice_set__name',
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
to_field_name='name'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
@ -91,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label=_('Search'),
|
||||||
|
)
|
||||||
|
choice = MultiValueCharFilter(
|
||||||
|
method='filter_by_choice'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'description', 'order_alphabetically',
|
||||||
|
]
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
|
Q(extra_choices__contains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_by_choice(self, queryset, name, value):
|
||||||
|
# TODO: Support case-insensitive matching
|
||||||
|
return queryset.filter(extra_choices__overlap=value)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkFilterSet(BaseFilterSet):
|
class CustomLinkFilterSet(BaseFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
@ -159,12 +199,12 @@ class SavedFilterFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
)
|
)
|
||||||
user = django_filters.ModelMultipleChoiceFilter(
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='user__username',
|
field_name='user__username',
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
to_field_name='username',
|
to_field_name='username',
|
||||||
label=_('User (name)'),
|
label=_('User (name)'),
|
||||||
)
|
)
|
||||||
@ -198,6 +238,26 @@ class SavedFilterFilterSet(BaseFilterSet):
|
|||||||
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkFilterSet(BaseFilterSet):
|
||||||
|
created = django_filters.DateTimeFilter()
|
||||||
|
object_type_id = MultiValueNumberFilter()
|
||||||
|
object_type = ContentTypeFilter()
|
||||||
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
label=_('User (ID)'),
|
||||||
|
)
|
||||||
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='user__username',
|
||||||
|
queryset=get_user_model().objects.all(),
|
||||||
|
to_field_name='username',
|
||||||
|
label=_('User (name)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = ['id', 'object_id']
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
@ -223,12 +283,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
|
|||||||
queryset=ContentType.objects.all()
|
queryset=ContentType.objects.all()
|
||||||
)
|
)
|
||||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
)
|
)
|
||||||
created_by = django_filters.ModelMultipleChoiceFilter(
|
created_by = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='created_by__username',
|
field_name='created_by__username',
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
to_field_name='username',
|
to_field_name='username',
|
||||||
label=_('User (name)'),
|
label=_('User (name)'),
|
||||||
)
|
)
|
||||||
@ -257,10 +317,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
content_type_id = MultiValueNumberFilter(
|
content_type_id = MultiValueNumberFilter(
|
||||||
method='_content_type_id'
|
method='_content_type_id'
|
||||||
)
|
)
|
||||||
|
for_object_type_id = MultiValueNumberFilter(
|
||||||
|
method='_for_object_type'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@ -297,6 +360,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
|
|
||||||
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
|
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
|
||||||
|
|
||||||
|
def _for_object_type(self, queryset, name, values):
|
||||||
|
return queryset.filter(
|
||||||
|
Q(object_types__id__in=values) | Q(object_types__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
@ -510,12 +578,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
|||||||
queryset=ContentType.objects.all()
|
queryset=ContentType.objects.all()
|
||||||
)
|
)
|
||||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
label=_('User (ID)'),
|
label=_('User (ID)'),
|
||||||
)
|
)
|
||||||
user = django_filters.ModelMultipleChoiceFilter(
|
user = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='user__username',
|
field_name='user__username',
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
to_field_name='username',
|
to_field_name='username',
|
||||||
label=_('User name'),
|
label=_('User name'),
|
||||||
)
|
)
|
||||||
@ -557,3 +625,27 @@ class ContentTypeFilterSet(django_filters.FilterSet):
|
|||||||
Q(app_label__icontains=value) |
|
Q(app_label__icontains=value) |
|
||||||
Q(model__icontains=value)
|
Q(model__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# ConfigRevisions
|
||||||
|
#
|
||||||
|
|
||||||
|
class ConfigRevisionFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label=_('Search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConfigRevision
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
]
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(comment__icontains=value)
|
||||||
|
)
|
||||||
|
@ -4,5 +4,4 @@ from .bulk_edit import *
|
|||||||
from .bulk_import import *
|
from .bulk_import import *
|
||||||
from .misc import *
|
from .misc import *
|
||||||
from .mixins import *
|
from .mixins import *
|
||||||
from .config import *
|
|
||||||
from .scripts import *
|
from .scripts import *
|
||||||
|
@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from utilities.forms import BulkEditForm, add_blank_choice
|
from utilities.forms import BulkEditForm, add_blank_choice
|
||||||
from utilities.forms.fields import ColorField
|
from utilities.forms.fields import ColorField, DynamicModelChoiceField
|
||||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextBulkEditForm',
|
'ConfigContextBulkEditForm',
|
||||||
'ConfigTemplateBulkEditForm',
|
'ConfigTemplateBulkEditForm',
|
||||||
'CustomFieldBulkEditForm',
|
'CustomFieldBulkEditForm',
|
||||||
|
'CustomFieldChoiceSetBulkEditForm',
|
||||||
'CustomLinkBulkEditForm',
|
'CustomLinkBulkEditForm',
|
||||||
'ExportTemplateBulkEditForm',
|
'ExportTemplateBulkEditForm',
|
||||||
'JournalEntryBulkEditForm',
|
'JournalEntryBulkEditForm',
|
||||||
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
weight = forms.IntegerField(
|
weight = forms.IntegerField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
choice_set = DynamicModelChoiceField(
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
ui_visibility = forms.ChoiceField(
|
ui_visibility = forms.ChoiceField(
|
||||||
label=_("UI visibility"),
|
label=_("UI visibility"),
|
||||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||||
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
|||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
|
|
||||||
nullable_fields = ('group_name', 'description',)
|
nullable_fields = ('group_name', 'description', 'choice_set')
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
|
||||||
|
pk = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
widget=forms.MultipleHiddenInput
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
order_alphabetically = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect()
|
||||||
|
)
|
||||||
|
|
||||||
|
nullable_fields = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkBulkEditForm(BulkEditForm):
|
class CustomLinkBulkEditForm(BulkEditForm):
|
||||||
|
@ -9,10 +9,13 @@ from extras.models import *
|
|||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from utilities.forms import CSVModelForm
|
from utilities.forms import CSVModelForm
|
||||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
|
from utilities.forms.fields import (
|
||||||
|
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigTemplateImportForm',
|
'ConfigTemplateImportForm',
|
||||||
|
'CustomFieldChoiceSetImportForm',
|
||||||
'CustomFieldImportForm',
|
'CustomFieldImportForm',
|
||||||
'CustomLinkImportForm',
|
'CustomLinkImportForm',
|
||||||
'ExportTemplateImportForm',
|
'ExportTemplateImportForm',
|
||||||
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_("Object type (for object or multi-object fields)")
|
help_text=_("Object type (for object or multi-object fields)")
|
||||||
)
|
)
|
||||||
choices = SimpleArrayField(
|
choice_set = CSVModelChoiceField(
|
||||||
base_field=forms.CharField(),
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Comma-separated list of field choices')
|
help_text=_('Choice set (for selection fields)')
|
||||||
)
|
)
|
||||||
ui_visibility = CSVChoiceField(
|
ui_visibility = CSVChoiceField(
|
||||||
choices=CustomFieldVisibilityChoices,
|
choices=CustomFieldVisibilityChoices,
|
||||||
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
|
|||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
||||||
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
|
||||||
'validation_regex', 'ui_visibility', 'is_cloneable',
|
'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||||
|
extra_choices = SimpleArrayField(
|
||||||
|
base_field=forms.CharField(),
|
||||||
|
required=False,
|
||||||
|
help_text=_('Comma-separated list of field choices')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
fields = (
|
||||||
|
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,82 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from netbox.config import get_config, PARAMS
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'ConfigRevisionForm',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
EMPTY_VALUES = ('', None, [], ())
|
|
||||||
|
|
||||||
|
|
||||||
class FormMetaclass(forms.models.ModelFormMetaclass):
|
|
||||||
|
|
||||||
def __new__(mcs, name, bases, attrs):
|
|
||||||
|
|
||||||
# Emulate a declared field for each supported configuration parameter
|
|
||||||
param_fields = {}
|
|
||||||
for param in PARAMS:
|
|
||||||
field_kwargs = {
|
|
||||||
'required': False,
|
|
||||||
'label': param.label,
|
|
||||||
'help_text': param.description,
|
|
||||||
}
|
|
||||||
field_kwargs.update(**param.field_kwargs)
|
|
||||||
param_fields[param.name] = param.field(**field_kwargs)
|
|
||||||
attrs.update(param_fields)
|
|
||||||
|
|
||||||
return super().__new__(mcs, name, bases, attrs)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
|
|
||||||
"""
|
|
||||||
Form for creating a new ConfigRevision.
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
widgets = {
|
|
||||||
'comment': forms.Textarea(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
# Append current parameter values to form field help texts and check for static configurations
|
|
||||||
config = get_config()
|
|
||||||
for param in PARAMS:
|
|
||||||
value = getattr(config, param.name)
|
|
||||||
is_static = hasattr(settings, param.name)
|
|
||||||
if value:
|
|
||||||
help_text = self.fields[param.name].help_text
|
|
||||||
if help_text:
|
|
||||||
help_text += '<br />' # Line break
|
|
||||||
help_text += f'Current value: <strong>{value}</strong>'
|
|
||||||
if is_static:
|
|
||||||
help_text += ' (defined statically)'
|
|
||||||
elif value == param.default:
|
|
||||||
help_text += ' (default)'
|
|
||||||
self.fields[param.name].help_text = help_text
|
|
||||||
if is_static:
|
|
||||||
self.fields[param.name].disabled = True
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
instance = super().save(commit=False)
|
|
||||||
|
|
||||||
# Populate JSON data on the instance
|
|
||||||
instance.data = self.render_json()
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
instance.save()
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def render_json(self):
|
|
||||||
json = {}
|
|
||||||
|
|
||||||
# Iterate through each field and populate non-empty values
|
|
||||||
for field_name in self.declared_fields:
|
|
||||||
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
|
|
||||||
json[field_name] = self.cleaned_data[field_name]
|
|
||||||
|
|
||||||
return json
|
|
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
@ -18,7 +18,9 @@ from .mixins import SavedFiltersMixin
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextFilterForm',
|
'ConfigContextFilterForm',
|
||||||
|
'ConfigRevisionFilterForm',
|
||||||
'ConfigTemplateFilterForm',
|
'ConfigTemplateFilterForm',
|
||||||
|
'CustomFieldChoiceSetFilterForm',
|
||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
'CustomLinkFilterForm',
|
'CustomLinkFilterForm',
|
||||||
'ExportTemplateFilterForm',
|
'ExportTemplateFilterForm',
|
||||||
@ -36,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
('Attributes', (
|
('Attributes', (
|
||||||
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
|
'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
|
||||||
|
'is_cloneable',
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
@ -61,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
choice_set_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Choice set')
|
||||||
|
)
|
||||||
ui_visibility = forms.ChoiceField(
|
ui_visibility = forms.ChoiceField(
|
||||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||||
required=False,
|
required=False,
|
||||||
@ -74,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id', 'choice')),
|
||||||
|
)
|
||||||
|
choice = forms.CharField(
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
|
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
||||||
@ -244,6 +261,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Tagged object type')
|
label=_('Tagged object type')
|
||||||
)
|
)
|
||||||
|
for_object_type_id = ContentTypeChoiceField(
|
||||||
|
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
||||||
|
required=False,
|
||||||
|
label=_('Allowed object type')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
@ -385,7 +407,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
widget=DateTimePicker()
|
widget=DateTimePicker()
|
||||||
)
|
)
|
||||||
created_by_id = DynamicModelMultipleChoiceField(
|
created_by_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('User'),
|
label=_('User'),
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
@ -429,7 +451,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
user_id = DynamicModelMultipleChoiceField(
|
user_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=get_user_model().objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('User'),
|
label=_('User'),
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
@ -444,3 +466,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
api_url='/api/extras/content-types/',
|
api_url='/api/extras/content-types/',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id')),
|
||||||
|
)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -10,18 +11,24 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||||
SlugField,
|
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||||
)
|
)
|
||||||
|
from utilities.forms.widgets import ArrayWidget
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkForm',
|
||||||
'ConfigContextForm',
|
'ConfigContextForm',
|
||||||
|
'ConfigRevisionForm',
|
||||||
'ConfigTemplateForm',
|
'ConfigTemplateForm',
|
||||||
|
'CustomFieldChoiceSetForm',
|
||||||
'CustomFieldForm',
|
'CustomFieldForm',
|
||||||
'CustomLinkForm',
|
'CustomLinkForm',
|
||||||
'ExportTemplateForm',
|
'ExportTemplateForm',
|
||||||
@ -45,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||||
)
|
)
|
||||||
|
choice_set = DynamicModelChoiceField(
|
||||||
|
queryset=CustomFieldChoiceSet.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Custom Field', (
|
('Custom Field', (
|
||||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
||||||
)),
|
)),
|
||||||
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
|
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
|
||||||
('Values', ('default', 'choices')),
|
('Values', ('default', 'choice_set')),
|
||||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -73,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
self.fields['type'].disabled = True
|
self.fields['type'].disabled = True
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||||
|
extra_choices = forms.CharField(
|
||||||
|
widget=ArrayWidget(),
|
||||||
|
help_text=_('Enter one choice per line.')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
|
||||||
|
|
||||||
|
def clean_extra_choices(self):
|
||||||
|
return self.cleaned_data['extra_choices'].splitlines()
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
@ -165,6 +190,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
|||||||
super().__init__(*args, initial=initial, **kwargs)
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
||||||
|
object_type = ContentTypeChoiceField(
|
||||||
|
queryset=ContentType.objects.all(),
|
||||||
|
limit_choices_to=FeatureQuery('bookmarks').get_query()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = ('object_type', 'object_id')
|
||||||
|
|
||||||
|
|
||||||
class WebhookForm(BootstrapMixin, forms.ModelForm):
|
class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
@ -200,15 +236,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class TagForm(BootstrapMixin, forms.ModelForm):
|
class TagForm(BootstrapMixin, forms.ModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
object_types = ContentTypeMultipleChoiceField(
|
||||||
|
queryset=ContentType.objects.all(),
|
||||||
|
limit_choices_to=FeatureQuery('tags'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Tag', ('name', 'slug', 'color', 'description')),
|
('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'slug', 'color', 'description'
|
'name', 'slug', 'color', 'description', 'object_types',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -374,3 +415,99 @@ class JournalEntryForm(NetBoxModelForm):
|
|||||||
'assigned_object_type': forms.HiddenInput,
|
'assigned_object_type': forms.HiddenInput,
|
||||||
'assigned_object_id': forms.HiddenInput,
|
'assigned_object_id': forms.HiddenInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_VALUES = ('', None, [], ())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
|
||||||
|
|
||||||
|
def __new__(mcs, name, bases, attrs):
|
||||||
|
|
||||||
|
# Emulate a declared field for each supported configuration parameter
|
||||||
|
param_fields = {}
|
||||||
|
for param in PARAMS:
|
||||||
|
field_kwargs = {
|
||||||
|
'required': False,
|
||||||
|
'label': param.label,
|
||||||
|
'help_text': param.description,
|
||||||
|
}
|
||||||
|
field_kwargs.update(**param.field_kwargs)
|
||||||
|
param_fields[param.name] = param.field(**field_kwargs)
|
||||||
|
attrs.update(param_fields)
|
||||||
|
|
||||||
|
return super().__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
|
||||||
|
"""
|
||||||
|
Form for creating a new ConfigRevision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Rack Elevations', ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
|
||||||
|
('Power', ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
|
||||||
|
('IPAM', ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
|
||||||
|
('Security', ('ALLOWED_URL_SCHEMES',)),
|
||||||
|
('Banners', ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||||
|
('Pagination', ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||||
|
('Validation', ('CUSTOM_VALIDATORS',)),
|
||||||
|
('User Preferences', ('DEFAULT_USER_PREFERENCES',)),
|
||||||
|
('Miscellaneous', ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
|
||||||
|
('Config Revision', ('comment',))
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConfigRevision
|
||||||
|
fields = '__all__'
|
||||||
|
widgets = {
|
||||||
|
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'comment': forms.Textarea(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Append current parameter values to form field help texts and check for static configurations
|
||||||
|
config = get_config()
|
||||||
|
for param in PARAMS:
|
||||||
|
value = getattr(config, param.name)
|
||||||
|
is_static = hasattr(settings, param.name)
|
||||||
|
if value:
|
||||||
|
help_text = self.fields[param.name].help_text
|
||||||
|
if help_text:
|
||||||
|
help_text += '<br />' # Line break
|
||||||
|
help_text += f'Current value: <strong>{value}</strong>'
|
||||||
|
if is_static:
|
||||||
|
help_text += ' (defined statically)'
|
||||||
|
elif value == param.default:
|
||||||
|
help_text += ' (default)'
|
||||||
|
self.fields[param.name].help_text = help_text
|
||||||
|
self.fields[param.name].initial = value
|
||||||
|
if is_static:
|
||||||
|
self.fields[param.name].disabled = True
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
# Populate JSON data on the instance
|
||||||
|
instance.data = self.render_json()
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def render_json(self):
|
||||||
|
json = {}
|
||||||
|
|
||||||
|
# Iterate through each field and populate non-empty values
|
||||||
|
for field_name in self.declared_fields:
|
||||||
|
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
|
||||||
|
json[field_name] = self.cleaned_data[field_name]
|
||||||
|
|
||||||
|
return json
|
||||||
|
@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
self.cleaned_data['_schedule_at'] = local_now()
|
self.cleaned_data['_schedule_at'] = local_now()
|
||||||
|
|
||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
@property
|
|
||||||
def requires_input(self):
|
|
||||||
"""
|
|
||||||
A boolean indicating whether the form requires user input (ignore the built-in fields).
|
|
||||||
"""
|
|
||||||
return bool(len(self.fields) > 3)
|
|
||||||
|
@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
|
|||||||
def resolve_custom_field_list(root, info, **kwargs):
|
def resolve_custom_field_list(root, info, **kwargs):
|
||||||
return gql_query_optimizer(models.CustomField.objects.all(), info)
|
return gql_query_optimizer(models.CustomField.objects.all(), info)
|
||||||
|
|
||||||
|
custom_field_choice_set = ObjectField(CustomFieldChoiceSetType)
|
||||||
|
custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType)
|
||||||
|
|
||||||
|
def resolve_custom_field_choices_list(root, info, **kwargs):
|
||||||
|
return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info)
|
||||||
|
|
||||||
custom_link = ObjectField(CustomLinkType)
|
custom_link = ObjectField(CustomLinkType)
|
||||||
custom_link_list = ObjectListField(CustomLinkType)
|
custom_link_list = ObjectListField(CustomLinkType)
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextType',
|
'ConfigContextType',
|
||||||
'ConfigTemplateType',
|
'ConfigTemplateType',
|
||||||
|
'CustomFieldChoiceSetType',
|
||||||
'CustomFieldType',
|
'CustomFieldType',
|
||||||
'CustomLinkType',
|
'CustomLinkType',
|
||||||
'ExportTemplateType',
|
'ExportTemplateType',
|
||||||
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
|
|||||||
filterset_class = filtersets.CustomFieldFilterSet
|
filterset_class = filtersets.CustomFieldFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetType(ObjectType):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.CustomFieldChoiceSet
|
||||||
|
fields = '__all__'
|
||||||
|
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkType(ObjectType):
|
class CustomLinkType(ObjectType):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -4,7 +4,7 @@ import sys
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
@ -63,6 +63,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
logger.info(f"Script completed in {job.duration}")
|
logger.info(f"Script completed in {job.duration}")
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
# Params
|
# Params
|
||||||
script = options['script']
|
script = options['script']
|
||||||
loglevel = options['loglevel']
|
loglevel = options['loglevel']
|
||||||
|
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal file
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-06-22 14:14
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0092_delete_jobresult'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='configrevision',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
]
|
23
netbox/extras/migrations/0094_tag_object_types.py
Normal file
23
netbox/extras/migrations/0094_tag_object_types.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import extras.utils
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('extras', '0093_configrevision_ordering'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tag',
|
||||||
|
name='object_types',
|
||||||
|
field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'),
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='taggeditem',
|
||||||
|
new_name='extras_tagg_content_717743_idx',
|
||||||
|
old_fields=('content_type', 'object_id'),
|
||||||
|
),
|
||||||
|
]
|
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-06-29 14:07
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('extras', '0094_tag_object_types'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bookmark',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('object_id', models.PositiveBigIntegerField()),
|
||||||
|
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('created', 'pk'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='bookmark',
|
||||||
|
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'),
|
||||||
|
),
|
||||||
|
]
|
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal file
61
netbox/extras/migrations/0096_customfieldchoiceset.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from extras.choices import CustomFieldTypeChoices
|
||||||
|
|
||||||
|
|
||||||
|
def create_choice_sets(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Create a CustomFieldChoiceSet for each CustomField with choices defined.
|
||||||
|
"""
|
||||||
|
CustomField = apps.get_model('extras', 'CustomField')
|
||||||
|
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
|
||||||
|
|
||||||
|
# Create custom field choice sets
|
||||||
|
choice_fields = CustomField.objects.filter(
|
||||||
|
type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT),
|
||||||
|
choices__len__gt=0
|
||||||
|
)
|
||||||
|
for cf in choice_fields:
|
||||||
|
choiceset = CustomFieldChoiceSet.objects.create(
|
||||||
|
name=f'{cf.name} Choices',
|
||||||
|
extra_choices=cf.choices
|
||||||
|
)
|
||||||
|
cf.choice_set = choiceset
|
||||||
|
|
||||||
|
# Update custom fields to point to new choice sets
|
||||||
|
CustomField.objects.bulk_update(choice_fields, ['choice_set'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0095_bookmarks'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomFieldChoiceSet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('description', models.CharField(blank=True, max_length=200)),
|
||||||
|
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)),
|
||||||
|
('order_alphabetically', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('name',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='choice_set',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=create_choice_sets,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal file
17
netbox/extras/migrations/0097_customfield_remove_choices.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.10 on 2023-07-17 15:22
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0096_customfieldchoiceset'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='customfield',
|
||||||
|
name='choices',
|
||||||
|
),
|
||||||
|
]
|
@ -1,6 +1,6 @@
|
|||||||
from .change_logging import *
|
from .change_logging import *
|
||||||
from .configs import *
|
from .configs import *
|
||||||
from .customfields import CustomField
|
from .customfields import *
|
||||||
from .dashboard import *
|
from .dashboard import *
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import *
|
from .reports import *
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from ..querysets import ObjectChangeQuerySet
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ObjectChange',
|
'ObjectChange',
|
||||||
@ -24,7 +24,7 @@ class ObjectChange(models.Model):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name='changes',
|
related_name='changes',
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -82,7 +82,7 @@ class ObjectChange(models.Model):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = ObjectChangeQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-time']
|
ordering = ['-time']
|
||||||
|
@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
|||||||
Synchronize context data from the designated DataFile (if any).
|
Synchronize context data from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.data = self.data_file.get_data()
|
self.data = self.data_file.get_data()
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextModel(models.Model):
|
class ConfigContextModel(models.Model):
|
||||||
@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
|||||||
Synchronize template content from the designated DataFile (if any).
|
Synchronize template content from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.template_code = self.data_file.data_as_string
|
self.template_code = self.data_file.data_as_string
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
def render(self, context=None):
|
def render(self, context=None):
|
||||||
"""
|
"""
|
||||||
|
@ -31,6 +31,7 @@ from utilities.validators import validate_regex
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CustomField',
|
'CustomField',
|
||||||
|
'CustomFieldChoiceSet',
|
||||||
'CustomFieldManager',
|
'CustomFieldManager',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
choices = ArrayField(
|
choice_set = models.ForeignKey(
|
||||||
base_field=models.CharField(max_length=100),
|
to='CustomFieldChoiceSet',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='choices_for',
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True
|
||||||
help_text=_('Comma-separated list of available choices (for selection fields)')
|
|
||||||
)
|
)
|
||||||
ui_visibility = models.CharField(
|
ui_visibility = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||||
'ui_visibility', 'is_cloneable',
|
'choice_set', 'ui_visibility', 'is_cloneable',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
def search_type(self):
|
def search_type(self):
|
||||||
return SEARCH_TYPES.get(self.type)
|
return SEARCH_TYPES.get(self.type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def choices(self):
|
||||||
|
if self.choice_set:
|
||||||
|
return self.choice_set.choices
|
||||||
|
return []
|
||||||
|
|
||||||
def populate_initial_data(self, content_types):
|
def populate_initial_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||||
@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
'validation_regex': "Regular expression validation is supported only for text and URL fields"
|
'validation_regex': "Regular expression validation is supported only for text and URL fields"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Choices can be set only on selection fields
|
# Choice set must be set on selection fields, and *only* on selection fields
|
||||||
if self.choices and self.type not in (
|
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
|
||||||
CustomFieldTypeChoices.TYPE_MULTISELECT
|
|
||||||
):
|
|
||||||
raise ValidationError({
|
|
||||||
'choices': "Choices may be set only for custom selection fields."
|
|
||||||
})
|
|
||||||
|
|
||||||
# Selection fields must have at least one choice defined
|
|
||||||
if self.type in (
|
if self.type in (
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
CustomFieldTypeChoices.TYPE_MULTISELECT
|
CustomFieldTypeChoices.TYPE_MULTISELECT
|
||||||
) and not self.choices:
|
):
|
||||||
|
if not self.choice_set:
|
||||||
|
raise ValidationError({
|
||||||
|
'choice_set': "Selection fields must specify a set of choices."
|
||||||
|
})
|
||||||
|
elif self.choice_set:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'choices': "Selection fields must specify at least one choice."
|
'choice_set': "Choices may be set only on selection fields."
|
||||||
})
|
})
|
||||||
|
|
||||||
# A selection field's default (if any) must be present in its available choices
|
# A selection field's default (if any) must be present in its available choices
|
||||||
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
elif self.required:
|
elif self.required:
|
||||||
raise ValidationError("Required field cannot be empty.")
|
raise ValidationError("Required field cannot be empty.")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
|
"""
|
||||||
|
Represents a set of choices available for choice and multi-choice custom fields.
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True
|
||||||
|
)
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
extra_choices = ArrayField(
|
||||||
|
base_field=models.CharField(max_length=100),
|
||||||
|
help_text=_('List of field choices')
|
||||||
|
)
|
||||||
|
order_alphabetically = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_('Choices are automatically ordered alphabetically on save')
|
||||||
|
)
|
||||||
|
|
||||||
|
clone_fields = ('extra_choices', 'order_alphabetically')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('name',)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('extras:customfieldchoiceset', args=[self.pk])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def choices(self):
|
||||||
|
return self.extra_choices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def choices_count(self):
|
||||||
|
return len(self.choices)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# Sort choices if alphabetical ordering is enforced
|
||||||
|
if self.order_alphabetically:
|
||||||
|
self.extra_choices = sorted(self.choices)
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.models import User
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -29,6 +28,7 @@ from utilities.querysets import RestrictedQuerySet
|
|||||||
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'Bookmark',
|
||||||
'ConfigRevision',
|
'ConfigRevision',
|
||||||
'CustomLink',
|
'CustomLink',
|
||||||
'ExportTemplate',
|
'ExportTemplate',
|
||||||
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
text = clean_html(text, allowed_schemes)
|
text = clean_html(text, allowed_schemes)
|
||||||
|
|
||||||
# Sanitize link
|
# Sanitize link
|
||||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
|
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
|
||||||
|
|
||||||
# Verify link scheme is allowed
|
# Verify link scheme is allowed
|
||||||
result = urllib.parse.urlparse(link)
|
result = urllib.parse.urlparse(link)
|
||||||
@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
|
|||||||
Synchronize template content from the designated DataFile (if any).
|
Synchronize template content from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.template_code = self.data_file.data_as_string
|
self.template_code = self.data_file.data_as_string
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
def render(self, queryset):
|
def render(self, queryset):
|
||||||
"""
|
"""
|
||||||
@ -418,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
@ -558,7 +559,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
|||||||
fk_field='assigned_object_id'
|
fk_field='assigned_object_id'
|
||||||
)
|
)
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
to=User,
|
to=settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
@ -593,6 +594,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
|||||||
return JournalEntryKindChoices.colors.get(self.kind)
|
return JournalEntryKindChoices.colors.get(self.kind)
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(models.Model):
|
||||||
|
"""
|
||||||
|
An object bookmarked by a User.
|
||||||
|
"""
|
||||||
|
created = models.DateTimeField(
|
||||||
|
auto_now_add=True
|
||||||
|
)
|
||||||
|
object_type = models.ForeignKey(
|
||||||
|
to=ContentType,
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
object_id = models.PositiveBigIntegerField()
|
||||||
|
object = GenericForeignKey(
|
||||||
|
ct_field='object_type',
|
||||||
|
fk_field='object_id'
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('created', 'pk')
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('object_type', 'object_id', 'user'),
|
||||||
|
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.object:
|
||||||
|
return str(self.object)
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevision(models.Model):
|
class ConfigRevision(models.Model):
|
||||||
"""
|
"""
|
||||||
An atomic revision of NetBox's configuration.
|
An atomic revision of NetBox's configuration.
|
||||||
@ -610,6 +649,11 @@ class ConfigRevision(models.Model):
|
|||||||
verbose_name='Configuration data'
|
verbose_name='Configuration data'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'Config revision #{self.pk} ({self.created})'
|
return f'Config revision #{self.pk} ({self.created})'
|
||||||
|
|
||||||
@ -618,12 +662,16 @@ class ConfigRevision(models.Model):
|
|||||||
return self.data[item]
|
return self.data[item]
|
||||||
return super().__getattribute__(item)
|
return super().__getattribute__(item)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('extras:configrevision', args=[self.pk])
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
"""
|
"""
|
||||||
Cache the configuration data.
|
Cache the configuration data.
|
||||||
"""
|
"""
|
||||||
cache.set('config', self.data, None)
|
cache.set('config', self.data, None)
|
||||||
cache.set('config_version', self.pk, None)
|
cache.set('config_version', self.pk, None)
|
||||||
|
activate.alters_data = True
|
||||||
|
|
||||||
@admin.display(boolean=True)
|
@admin.display(boolean=True)
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
|
|||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from .mixins import PythonModuleMixin
|
from .mixins import PythonModuleMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger('netbox.reports')
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Report',
|
'Report',
|
||||||
'ReportModule',
|
'ReportModule',
|
||||||
@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
module = self.get_module()
|
module = self.get_module()
|
||||||
except ImportError:
|
except (ImportError, SyntaxError) as e:
|
||||||
|
logger.error(f"Unable to load report module {self.name}, exception: {e}")
|
||||||
return {}
|
return {}
|
||||||
reports = {}
|
reports = {}
|
||||||
ordered = getattr(module, 'report_order', [])
|
ordered = getattr(module, 'report_order', [])
|
||||||
|
@ -112,6 +112,7 @@ class StagedChange(ChangeLoggedModel):
|
|||||||
instance = self.model.objects.get(pk=self.object_id)
|
instance = self.model.objects.get(pk=self.object_id)
|
||||||
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
apply.alters_data = True
|
||||||
|
|
||||||
def get_action_color(self):
|
def get_action_color(self):
|
||||||
return ChangeActionChoices.colors.get(self.action)
|
return ChangeActionChoices.colors.get(self.action)
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from taggit.models import TagBase, GenericTaggedItemBase
|
from taggit.models import TagBase, GenericTaggedItemBase
|
||||||
|
|
||||||
|
from extras.utils import FeatureQuery
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||||
from utilities.choices import ColorChoices
|
from utilities.choices import ColorChoices
|
||||||
@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
object_types = models.ManyToManyField(
|
||||||
|
to=ContentType,
|
||||||
|
related_name='+',
|
||||||
|
limit_choices_to=FeatureQuery('tags'),
|
||||||
|
blank=True,
|
||||||
|
help_text=_("The object type(s) to which this this tag can be applied.")
|
||||||
|
)
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'color', 'description',
|
'color', 'description', 'object_types',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -61,6 +72,4 @@ class TaggedItem(GenericTaggedItemBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
index_together = (
|
indexes = [models.Index(fields=["content_type", "object_id"])]
|
||||||
("content_type", "object_id")
|
|
||||||
)
|
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.postgres.aggregates import JSONBAgg
|
from django.contrib.postgres.aggregates import JSONBAgg
|
||||||
from django.db.models import OuterRef, Subquery, Q
|
from django.db.models import OuterRef, Subquery, Q
|
||||||
|
from django.db.utils import ProgrammingError
|
||||||
|
|
||||||
from extras.models.tags import TaggedItem
|
from extras.models.tags import TaggedItem
|
||||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||||
@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectChangeQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def valid_models(self):
|
||||||
|
# Exclude any change records which refer to an instance of a model that's no longer installed. This
|
||||||
|
# can happen when a plugin is removed but its data remains in the database, for example.
|
||||||
|
try:
|
||||||
|
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
|
||||||
|
except ProgrammingError:
|
||||||
|
# Handle the case where the database schema has not yet been initialized
|
||||||
|
content_types = ContentType.objects.none()
|
||||||
|
|
||||||
|
content_type_ids = set(
|
||||||
|
ct.pk for ct in content_types
|
||||||
|
)
|
||||||
|
return self.filter(changed_object_type_id__in=content_type_ids)
|
||||||
|
@ -366,7 +366,7 @@ class BaseScript:
|
|||||||
if self.fieldsets:
|
if self.fieldsets:
|
||||||
fieldsets.extend(self.fieldsets)
|
fieldsets.extend(self.fieldsets)
|
||||||
else:
|
else:
|
||||||
fields = (name for name, _ in self._get_vars().items())
|
fields = list(name for name, _ in self._get_vars().items())
|
||||||
fieldsets.append(('Script Data', fields))
|
fieldsets.append(('Script Data', fields))
|
||||||
|
|
||||||
# Append the default fieldset if defined in the Meta class
|
# Append the default fieldset if defined in the Meta class
|
||||||
@ -390,6 +390,11 @@ class BaseScript:
|
|||||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||||
form.fields['_commit'].initial = self.commit_default
|
form.fields['_commit'].initial = self.commit_default
|
||||||
|
|
||||||
|
# Hide fields if scheduling has been disabled
|
||||||
|
if not self.scheduling_enabled:
|
||||||
|
form.fields['_schedule_at'].widget = forms.HiddenInput()
|
||||||
|
form.fields['_interval'].widget = forms.HiddenInput()
|
||||||
|
|
||||||
return form
|
return form
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
|
@ -10,8 +10,9 @@ from extras.validators import CustomValidator
|
|||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.context import current_request, webhooks_queue
|
from netbox.context import current_request, webhooks_queue
|
||||||
from netbox.signals import post_clean
|
from netbox.signals import post_clean
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
from .choices import ObjectChangeActionChoices
|
from .choices import ObjectChangeActionChoices
|
||||||
from .models import ConfigRevision, CustomField, ObjectChange
|
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
|
||||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs):
|
|||||||
Update the cached NetBox configuration when a new ConfigRevision is created.
|
Update the cached NetBox configuration when a new ConfigRevision is created.
|
||||||
"""
|
"""
|
||||||
instance.activate()
|
instance.activate()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Tags
|
||||||
|
#
|
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=TaggedItem)
|
||||||
|
def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
|
||||||
|
"""
|
||||||
|
Validate that any Tags being assigned to the instance are not restricted to non-applicable object types.
|
||||||
|
"""
|
||||||
|
if action != 'pre_add':
|
||||||
|
return
|
||||||
|
ct = ContentType.objects.get_for_model(instance)
|
||||||
|
# Retrieve any applied Tags that are restricted to certain object_types
|
||||||
|
for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
|
||||||
|
if ct not in tag.object_types.all():
|
||||||
|
raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
|
||||||
|
@ -2,14 +2,18 @@ import json
|
|||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from .template_code import *
|
from .template_code import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BookmarkTable',
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
|
'ConfigRevisionTable',
|
||||||
'ConfigTemplateTable',
|
'ConfigTemplateTable',
|
||||||
|
'CustomFieldChoiceSetTable',
|
||||||
'CustomFieldTable',
|
'CustomFieldTable',
|
||||||
'CustomLinkTable',
|
'CustomLinkTable',
|
||||||
'ExportTemplateTable',
|
'ExportTemplateTable',
|
||||||
@ -30,6 +34,29 @@ IMAGEATTACHMENT_IMAGE = '''
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
REVISION_BUTTONS = """
|
||||||
|
{% if not record.is_active %}
|
||||||
|
<a href="{% url 'extras:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
|
||||||
|
<i class="mdi mdi-file-restore"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionTable(NetBoxTable):
|
||||||
|
is_active = columns.BooleanColumn()
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('delete',),
|
||||||
|
extra_buttons=REVISION_BUTTONS
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = ConfigRevision
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'is_active', 'created', 'comment',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldTable(NetBoxTable):
|
class CustomFieldTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
@ -39,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
required = columns.BooleanColumn()
|
required = columns.BooleanColumn()
|
||||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||||
description = columns.MarkdownColumn()
|
description = columns.MarkdownColumn()
|
||||||
|
choices = columns.ArrayColumn(
|
||||||
|
max_items=10,
|
||||||
|
orderable=False,
|
||||||
|
verbose_name=_('Choices')
|
||||||
|
)
|
||||||
is_cloneable = columns.BooleanColumn()
|
is_cloneable = columns.BooleanColumn()
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
@ -51,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetTable(NetBoxTable):
|
||||||
|
name = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
choices = columns.ArrayColumn(
|
||||||
|
max_items=10,
|
||||||
|
accessor=tables.A('extra_choices'),
|
||||||
|
orderable=False,
|
||||||
|
verbose_name=_('Choices')
|
||||||
|
)
|
||||||
|
choice_count = tables.TemplateColumn(
|
||||||
|
accessor=tables.A('extra_choices'),
|
||||||
|
template_code='{{ value|length }}',
|
||||||
|
orderable=False,
|
||||||
|
verbose_name=_('Count')
|
||||||
|
)
|
||||||
|
order_alphabetically = columns.BooleanColumn()
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
|
||||||
|
'last_updated',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'name', 'choice_count', 'description')
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTable(NetBoxTable):
|
class CustomLinkTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
@ -143,6 +202,21 @@ class SavedFilterTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTable(NetBoxTable):
|
||||||
|
object_type = columns.ContentTypeColumn()
|
||||||
|
object = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('delete',)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = Bookmark
|
||||||
|
fields = ('pk', 'object', 'object_type', 'created')
|
||||||
|
default_columns = ('object', 'object_type', 'created')
|
||||||
|
|
||||||
|
|
||||||
class WebhookTable(NetBoxTable):
|
class WebhookTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
@ -186,10 +260,14 @@ class TagTable(NetBoxTable):
|
|||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
color = columns.ColorColumn()
|
color = columns.ColorColumn()
|
||||||
|
object_types = columns.ContentTypesColumn()
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions')
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated',
|
||||||
|
'actions',
|
||||||
|
)
|
||||||
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
|
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
@ -8,13 +8,15 @@ from rest_framework import status
|
|||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.reports import Report
|
from extras.reports import Report
|
||||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||||
from utilities.testing import APITestCase, APIViewTestCases
|
from utilities.testing import APITestCase, APIViewTestCases
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class AppTest(APITestCase):
|
class AppTest(APITestCase):
|
||||||
|
|
||||||
def test_root(self):
|
def test_root(self):
|
||||||
@ -96,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
|||||||
{
|
{
|
||||||
'content_types': ['dcim.site'],
|
'content_types': ['dcim.site'],
|
||||||
'name': 'cf6',
|
'name': 'cf6',
|
||||||
'type': 'select',
|
'type': 'text',
|
||||||
'choices': ['A', 'B', 'C']
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
@ -132,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
|||||||
cf.content_types.add(site_ct)
|
cf.content_types.add(site_ct)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
|
||||||
|
create_data = [
|
||||||
|
{
|
||||||
|
'name': 'Choice Set 4',
|
||||||
|
'extra_choices': ['4A', '4B', '4C'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Choice Set 5',
|
||||||
|
'extra_choices': ['5A', '5B', '5C'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Choice Set 6',
|
||||||
|
'extra_choices': ['6A', '6B', '6C'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
bulk_update_data = {
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
update_data = {
|
||||||
|
'name': 'Choice Set X',
|
||||||
|
'extra_choices': ['X1', 'X2', 'X3'],
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
choice_sets = (
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
|
||||||
|
)
|
||||||
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = CustomLink
|
model = CustomLink
|
||||||
brief_fields = ['display', 'id', 'name', 'url']
|
brief_fields = ['display', 'id', 'name', 'url']
|
||||||
@ -265,6 +302,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
|
|||||||
savedfilter.content_types.set([site_ct])
|
savedfilter.content_types.set([site_ct])
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTest(
|
||||||
|
APIViewTestCases.GetObjectViewTestCase,
|
||||||
|
APIViewTestCases.ListObjectsViewTestCase,
|
||||||
|
APIViewTestCases.CreateObjectViewTestCase,
|
||||||
|
APIViewTestCases.DeleteObjectViewTestCase
|
||||||
|
):
|
||||||
|
model = Bookmark
|
||||||
|
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
Site(name='Site 4', slug='site-4'),
|
||||||
|
Site(name='Site 5', slug='site-5'),
|
||||||
|
Site(name='Site 6', slug='site-6'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
sites = Site.objects.all()
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(object=sites[0], user=self.user),
|
||||||
|
Bookmark(object=sites[1], user=self.user),
|
||||||
|
Bookmark(object=sites[2], user=self.user),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
self.create_data = [
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[3].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[4].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'object_type': 'dcim.site',
|
||||||
|
'object_id': sites[5].pk,
|
||||||
|
'user': self.user.pk,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
brief_fields = ['display', 'id', 'name', 'url']
|
brief_fields = ['display', 'id', 'name', 'url']
|
||||||
@ -579,6 +668,7 @@ class ReportTest(APITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
||||||
|
from extras.api.views import ReportViewSet
|
||||||
ReportViewSet._get_report = self.get_test_report
|
ReportViewSet._get_report = self.get_test_report
|
||||||
|
|
||||||
def test_get_report(self):
|
def test_get_report(self):
|
||||||
@ -621,6 +711,7 @@ class ScriptTest(APITestCase):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
||||||
|
from extras.api.views import ScriptViewSet
|
||||||
ScriptViewSet._get_script = self.get_test_script
|
ScriptViewSet._get_script = self.get_test_script
|
||||||
|
|
||||||
def test_get_script(self):
|
def test_get_script(self):
|
||||||
|
@ -5,7 +5,7 @@ from rest_framework import status
|
|||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, ObjectChange, Tag
|
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
from utilities.testing.utils import create_tags, post_data
|
from utilities.testing.utils import create_tags, post_data
|
||||||
from utilities.testing.views import ModelViewTestCase
|
from utilities.testing.views import ModelViewTestCase
|
||||||
@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=['Bar', 'Foo']
|
||||||
|
)
|
||||||
|
|
||||||
# Create a custom field on the Site model
|
# Create a custom field on the Site model
|
||||||
ct = ContentType.objects.get_for_model(Site)
|
ct = ContentType.objects.get_for_model(Site)
|
||||||
cf = CustomField(
|
cf = CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
name='my_field',
|
name='cf1',
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
# Create a select custom field on the Site model
|
# Create a select custom field on the Site model
|
||||||
cf_select = CustomField(
|
cf_select = CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
name='my_field_select',
|
name='cf2',
|
||||||
required=False,
|
required=False,
|
||||||
choices=['Bar', 'Foo']
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf_select.save()
|
cf_select.save()
|
||||||
cf_select.content_types.set([ct])
|
cf_select.content_types.set([ct])
|
||||||
@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
'name': 'Site 1',
|
'name': 'Site 1',
|
||||||
'slug': 'site-1',
|
'slug': 'site-1',
|
||||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||||
'cf_my_field': 'ABC',
|
'cf_cf1': 'ABC',
|
||||||
'cf_my_field_select': 'Bar',
|
'cf_cf2': 'Bar',
|
||||||
'tags': [tag.pk for tag in tags],
|
'tags': [tag.pk for tag in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.changed_object, site)
|
self.assertEqual(oc.changed_object, site)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||||
self.assertEqual(oc.prechange_data, None)
|
self.assertEqual(oc.prechange_data, None)
|
||||||
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
|
||||||
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
'name': 'Site X',
|
'name': 'Site X',
|
||||||
'slug': 'site-x',
|
'slug': 'site-x',
|
||||||
'status': SiteStatusChoices.STATUS_PLANNED,
|
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||||
'cf_my_field': 'DEF',
|
'cf_cf1': 'DEF',
|
||||||
'cf_my_field_select': 'Foo',
|
'cf_cf2': 'Foo',
|
||||||
'tags': [tags[2].pk],
|
'tags': [tags[2].pk],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
self.assertEqual(oc.prechange_data['name'], 'Site 1')
|
self.assertEqual(oc.prechange_data['name'], 'Site 1')
|
||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
|
||||||
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
name='Site 1',
|
name='Site 1',
|
||||||
slug='site-1',
|
slug='site-1',
|
||||||
custom_field_data={
|
custom_field_data={
|
||||||
'my_field': 'ABC',
|
'cf1': 'ABC',
|
||||||
'my_field_select': 'Bar'
|
'cf2': 'Bar'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
site.save()
|
site.save()
|
||||||
@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.changed_object, None)
|
self.assertEqual(oc.changed_object, None)
|
||||||
self.assertEqual(oc.object_repr, site.name)
|
self.assertEqual(oc.object_repr, site.name)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
|
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
|
||||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
|
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
|
||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
ct = ContentType.objects.get_for_model(Site)
|
ct = ContentType.objects.get_for_model(Site)
|
||||||
cf = CustomField(
|
cf = CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
name='my_field',
|
name='cf1',
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([ct])
|
cf.content_types.set([ct])
|
||||||
|
|
||||||
# Create a select custom field on the Site model
|
# Create a select custom field on the Site model
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Choice Set 1',
|
||||||
|
extra_choices=['Bar', 'Foo']
|
||||||
|
)
|
||||||
cf_select = CustomField(
|
cf_select = CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
name='my_field_select',
|
name='cf2',
|
||||||
required=False,
|
required=False,
|
||||||
choices=['Bar', 'Foo']
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf_select.save()
|
cf_select.save()
|
||||||
cf_select.content_types.set([ct])
|
cf_select.content_types.set([ct])
|
||||||
@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
'name': 'Site 1',
|
'name': 'Site 1',
|
||||||
'slug': 'site-1',
|
'slug': 'site-1',
|
||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'my_field': 'ABC',
|
'cf1': 'ABC',
|
||||||
'my_field_select': 'Bar',
|
'cf2': 'Bar',
|
||||||
},
|
},
|
||||||
'tags': [
|
'tags': [
|
||||||
{'name': 'Tag 1'},
|
{'name': 'Tag 1'},
|
||||||
@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
'name': 'Site X',
|
'name': 'Site X',
|
||||||
'slug': 'site-x',
|
'slug': 'site-x',
|
||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'my_field': 'DEF',
|
'cf1': 'DEF',
|
||||||
'my_field_select': 'Foo',
|
'cf2': 'Foo',
|
||||||
},
|
},
|
||||||
'tags': [
|
'tags': [
|
||||||
{'name': 'Tag 3'}
|
{'name': 'Tag 3'}
|
||||||
@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
name='Site 1',
|
name='Site 1',
|
||||||
slug='site-1',
|
slug='site-1',
|
||||||
custom_field_data={
|
custom_field_data={
|
||||||
'my_field': 'ABC',
|
'cf1': 'ABC',
|
||||||
'my_field_select': 'Bar'
|
'cf2': 'Bar'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
site.save()
|
site.save()
|
||||||
@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.changed_object, None)
|
self.assertEqual(oc.changed_object, None)
|
||||||
self.assertEqual(oc.object_repr, site.name)
|
self.assertEqual(oc.object_repr, site.name)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
|
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
|
||||||
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
|
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
|
||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
|
|||||||
from dcim.forms import SiteImportForm
|
from dcim.forms import SiteImportForm
|
||||||
from dcim.models import Manufacturer, Rack, Site
|
from dcim.models import Manufacturer, Rack, Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField, CustomFieldChoiceSet
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
from utilities.testing import APITestCase, TestCase
|
from utilities.testing import APITestCase, TestCase
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
|
|||||||
CHOICES = ('Option A', 'Option B', 'Option C')
|
CHOICES = ('Option A', 'Option B', 'Option C')
|
||||||
value = CHOICES[1]
|
value = CHOICES[1]
|
||||||
|
|
||||||
|
# Create a set of custom field choices
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=CHOICES
|
||||||
|
)
|
||||||
|
|
||||||
# Create a custom field & check that initial value is null
|
# Create a custom field & check that initial value is null
|
||||||
cf = CustomField.objects.create(
|
cf = CustomField.objects.create(
|
||||||
name='select_field',
|
name='select_field',
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
required=False,
|
required=False,
|
||||||
choices=CHOICES
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf.content_types.set([self.object_type])
|
cf.content_types.set([self.object_type])
|
||||||
instance = Site.objects.first()
|
instance = Site.objects.first()
|
||||||
@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
|
|||||||
CHOICES = ['Option A', 'Option B', 'Option C']
|
CHOICES = ['Option A', 'Option B', 'Option C']
|
||||||
value = [CHOICES[1], CHOICES[2]]
|
value = [CHOICES[1], CHOICES[2]]
|
||||||
|
|
||||||
|
# Create a set of custom field choices
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=CHOICES
|
||||||
|
)
|
||||||
|
|
||||||
# Create a custom field & check that initial value is null
|
# Create a custom field & check that initial value is null
|
||||||
cf = CustomField.objects.create(
|
cf = CustomField.objects.create(
|
||||||
name='multiselect_field',
|
name='multiselect_field',
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
required=False,
|
required=False,
|
||||||
choices=CHOICES
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf.content_types.set([self.object_type])
|
cf.content_types.set([self.object_type])
|
||||||
instance = Site.objects.first()
|
instance = Site.objects.first()
|
||||||
@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
)
|
)
|
||||||
VLAN.objects.bulk_create(vlans)
|
VLAN.objects.bulk_create(vlans)
|
||||||
|
|
||||||
|
# Create a set of custom field choices
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=('Foo', 'Bar', 'Baz')
|
||||||
|
)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
|
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
|
||||||
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
||||||
@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
name='select_field',
|
name='select_field',
|
||||||
default='Foo',
|
default='Foo',
|
||||||
choices=(
|
choice_set=choice_set
|
||||||
'Foo', 'Bar', 'Baz'
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
name='multiselect_field',
|
name='multiselect_field',
|
||||||
default=['Foo'],
|
default=['Foo'],
|
||||||
choices=(
|
choice_set=choice_set
|
||||||
'Foo', 'Bar', 'Baz'
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
CustomField(
|
CustomField(
|
||||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
# Create a set of custom field choices
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=('Choice A', 'Choice B', 'Choice C')
|
||||||
|
)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
|
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
|
||||||
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
|
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
|
||||||
@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
|
|||||||
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
|
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
|
||||||
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
|
||||||
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
|
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
|
||||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
|
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
|
||||||
'Choice A', 'Choice B', 'Choice C',
|
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
|
||||||
]),
|
|
||||||
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
|
|
||||||
'Choice A', 'Choice B', 'Choice C',
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
for cf in custom_fields:
|
for cf in custom_fields:
|
||||||
cf.save()
|
cf.save()
|
||||||
@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
|
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=['A', 'B', 'C', 'X']
|
||||||
|
)
|
||||||
|
|
||||||
# Integer filtering
|
# Integer filtering
|
||||||
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||||
cf.save()
|
cf.save()
|
||||||
@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
cf = CustomField(
|
cf = CustomField(
|
||||||
name='cf9',
|
name='cf9',
|
||||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
choices=['Foo', 'Bar', 'Baz']
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
cf = CustomField(
|
cf = CustomField(
|
||||||
name='cf10',
|
name='cf10',
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
choices=['A', 'B', 'C', 'X']
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
'cf6': '2016-06-26',
|
'cf6': '2016-06-26',
|
||||||
'cf7': 'http://a.example.com',
|
'cf7': 'http://a.example.com',
|
||||||
'cf8': 'http://a.example.com',
|
'cf8': 'http://a.example.com',
|
||||||
'cf9': 'Foo',
|
'cf9': 'A',
|
||||||
'cf10': ['A', 'X'],
|
'cf10': ['A', 'X'],
|
||||||
'cf11': manufacturers[0].pk,
|
'cf11': manufacturers[0].pk,
|
||||||
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
|
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
|
||||||
@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
'cf6': '2016-06-27',
|
'cf6': '2016-06-27',
|
||||||
'cf7': 'http://b.example.com',
|
'cf7': 'http://b.example.com',
|
||||||
'cf8': 'http://b.example.com',
|
'cf8': 'http://b.example.com',
|
||||||
'cf9': 'Bar',
|
'cf9': 'B',
|
||||||
'cf10': ['B', 'X'],
|
'cf10': ['B', 'X'],
|
||||||
'cf11': manufacturers[1].pk,
|
'cf11': manufacturers[1].pk,
|
||||||
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
|
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
|
||||||
@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
'cf6': '2016-06-28',
|
'cf6': '2016-06-28',
|
||||||
'cf7': 'http://c.example.com',
|
'cf7': 'http://c.example.com',
|
||||||
'cf8': 'http://c.example.com',
|
'cf8': 'http://c.example.com',
|
||||||
'cf9': 'Baz',
|
'cf9': 'C',
|
||||||
'cf10': ['C', 'X'],
|
'cf10': ['C', 'X'],
|
||||||
'cf11': manufacturers[2].pk,
|
'cf11': manufacturers[2].pk,
|
||||||
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
|
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
|
||||||
@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase):
|
|||||||
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
|
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
def test_filter_select(self):
|
def test_filter_select(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_filter_multiselect(self):
|
def test_filter_multiselect(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
@ -18,13 +18,20 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
|
|||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.all()
|
||||||
filterset = CustomFieldFilterSet
|
filterset = CustomFieldFilterSet
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
choice_sets = (
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
|
||||||
|
)
|
||||||
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
CustomField(
|
CustomField(
|
||||||
@ -51,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
||||||
),
|
),
|
||||||
|
CustomField(
|
||||||
|
name='Custom Field 4',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
required=False,
|
||||||
|
weight=400,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||||
|
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
|
||||||
|
choice_set=choice_sets[0]
|
||||||
|
),
|
||||||
|
CustomField(
|
||||||
|
name='Custom Field 5',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
|
required=False,
|
||||||
|
weight=500,
|
||||||
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
|
||||||
|
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
|
||||||
|
choice_set=choice_sets[1]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
CustomField.objects.bulk_create(custom_fields)
|
CustomField.objects.bulk_create(custom_fields)
|
||||||
custom_fields[0].content_types.add(content_types[0])
|
custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
|
||||||
custom_fields[1].content_types.add(content_types[1])
|
custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
|
||||||
custom_fields[2].content_types.add(content_types[2])
|
custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
|
||||||
|
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
|
||||||
|
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
||||||
@ -64,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
def test_content_types(self):
|
def test_content_types(self):
|
||||||
params = {'content_types': 'dcim.site'}
|
params = {'content_types': 'dcim.site'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_required(self):
|
def test_required(self):
|
||||||
@ -83,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
|||||||
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
def test_choice_set(self):
|
||||||
|
params = {'choice_set': ['Choice Set 1', 'Choice Set 2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
filterset = CustomFieldChoiceSetFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
choice_sets = (
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
|
||||||
|
)
|
||||||
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
|
def test_name(self):
|
||||||
|
params = {'name': ['Choice Set 1', 'Choice Set 2']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_choice(self):
|
||||||
|
params = {'choice': ['A', 'D']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class WebhookTestCase(TestCase, BaseFilterSetTests):
|
class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = Webhook.objects.all()
|
queryset = Webhook.objects.all()
|
||||||
@ -362,6 +417,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTestCase(TestCase, BaseFilterSetTests):
|
||||||
|
queryset = Bookmark.objects.all()
|
||||||
|
filterset = BookmarkFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||||
|
|
||||||
|
users = (
|
||||||
|
User(username='User 1'),
|
||||||
|
User(username='User 2'),
|
||||||
|
User(username='User 3'),
|
||||||
|
)
|
||||||
|
User.objects.bulk_create(users)
|
||||||
|
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||||
|
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||||
|
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||||
|
)
|
||||||
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(
|
||||||
|
object=sites[0],
|
||||||
|
user=users[0],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=sites[1],
|
||||||
|
user=users[1],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=sites[2],
|
||||||
|
user=users[2],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[0],
|
||||||
|
user=users[0],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[1],
|
||||||
|
user=users[1],
|
||||||
|
),
|
||||||
|
Bookmark(
|
||||||
|
object=tenants[2],
|
||||||
|
user=users[2],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
def test_object_type(self):
|
||||||
|
params = {'object_type': 'dcim.site'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
|
def test_user(self):
|
||||||
|
users = User.objects.filter(username__startswith='User')
|
||||||
|
params = {'user': [users[0].username, users[1].username]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'user_id': [users[0].pk, users[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
filterset = ExportTemplateFilterSet
|
filterset = ExportTemplateFilterSet
|
||||||
@ -818,6 +944,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
content_types = {
|
||||||
|
'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||||
|
'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
|
||||||
|
}
|
||||||
|
|
||||||
tags = (
|
tags = (
|
||||||
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
|
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
|
||||||
@ -825,6 +955,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
|
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
|
||||||
)
|
)
|
||||||
Tag.objects.bulk_create(tags)
|
Tag.objects.bulk_create(tags)
|
||||||
|
tags[0].object_types.add(content_types['site'])
|
||||||
|
tags[1].object_types.add(content_types['provider'])
|
||||||
|
|
||||||
# Apply some tags so we can filter by content type
|
# Apply some tags so we can filter by content type
|
||||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||||
@ -857,6 +989,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'content_type_id': [site_ct, provider_ct]}
|
params = {'content_type_id': [site_ct, provider_ct]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_object_types(self):
|
||||||
|
params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
|
||||||
|
self.assertEqual(
|
||||||
|
list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
|
||||||
|
['Tag 1', 'Tag 3']
|
||||||
|
)
|
||||||
|
params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]}
|
||||||
|
self.assertEqual(
|
||||||
|
list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
|
||||||
|
['Tag 2', 'Tag 3']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.all()
|
||||||
|
@ -5,7 +5,7 @@ from dcim.forms import SiteForm
|
|||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import CustomFieldTypeChoices
|
from extras.choices import CustomFieldTypeChoices
|
||||||
from extras.forms import SavedFilterForm
|
from extras.forms import SavedFilterForm
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField, CustomFieldChoiceSet
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldModelFormTest(TestCase):
|
class CustomFieldModelFormTest(TestCase):
|
||||||
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
obj_type = ContentType.objects.get_for_model(Site)
|
obj_type = ContentType.objects.get_for_model(Site)
|
||||||
CHOICES = ('A', 'B', 'C')
|
choice_set = CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Custom Field Choice Set 1',
|
||||||
|
extra_choices=('A', 'B', 'C')
|
||||||
|
)
|
||||||
|
|
||||||
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
||||||
cf_text.content_types.set([obj_type])
|
cf_text.content_types.set([obj_type])
|
||||||
@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase):
|
|||||||
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
|
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
|
||||||
cf_json.content_types.set([obj_type])
|
cf_json.content_types.set([obj_type])
|
||||||
|
|
||||||
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
|
cf_select = CustomField.objects.create(
|
||||||
|
name='select',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||||
|
choice_set=choice_set
|
||||||
|
)
|
||||||
cf_select.content_types.set([obj_type])
|
cf_select.content_types.set([obj_type])
|
||||||
|
|
||||||
cf_multiselect = CustomField.objects.create(
|
cf_multiselect = CustomField.objects.create(
|
||||||
name='multiselect',
|
name='multiselect',
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||||
choices=CHOICES
|
choice_set=choice_set
|
||||||
)
|
)
|
||||||
cf_multiselect.content_types.set([obj_type])
|
cf_multiselect.content_types.set([obj_type])
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||||
from extras.models import ConfigContext, Tag
|
from extras.models import ConfigContext, Tag
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
|
from utilities.exceptions import AbortRequest
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
|
|
||||||
|
|
||||||
@ -14,6 +16,22 @@ class TagTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(tag.slug, 'testing-unicode-台灣')
|
self.assertEqual(tag.slug, 'testing-unicode-台灣')
|
||||||
|
|
||||||
|
def test_object_type_validation(self):
|
||||||
|
region = Region.objects.create(name='Region 1', slug='region-1')
|
||||||
|
sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
|
||||||
|
|
||||||
|
# Create a Tag that can only be applied to Regions
|
||||||
|
tag = Tag.objects.create(name='Tag 1', slug='tag-1')
|
||||||
|
tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region'))
|
||||||
|
|
||||||
|
# Apply the Tag to a Region
|
||||||
|
region.tags.add(tag)
|
||||||
|
self.assertIn(tag, region.tags.all())
|
||||||
|
|
||||||
|
# Apply the Tag to a SiteGroup
|
||||||
|
with self.assertRaises(AbortRequest):
|
||||||
|
sitegroup.tags.add(tag)
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextTest(TestCase):
|
class ConfigContextTest(TestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@ -11,6 +11,9 @@ from extras.models import *
|
|||||||
from utilities.testing import ViewTestCases, TestCase
|
from utilities.testing import ViewTestCases, TestCase
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = CustomField
|
model = CustomField
|
||||||
|
|
||||||
@ -18,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
site_ct = ContentType.objects.get_for_model(Site)
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
CustomFieldChoiceSet.objects.create(
|
||||||
|
name='Choice Set 1',
|
||||||
|
extra_choices=('A', 'B', 'C')
|
||||||
|
)
|
||||||
|
|
||||||
custom_fields = (
|
custom_fields = (
|
||||||
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
|
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
|
||||||
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
|
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
|
||||||
@ -41,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
||||||
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
|
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
|
||||||
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
|
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
|
||||||
'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
|
'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write',
|
||||||
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
|
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
|
model = CustomFieldChoiceSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
choice_sets = (
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
|
||||||
|
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
|
||||||
|
)
|
||||||
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'name': 'Choice Set X',
|
||||||
|
'extra_choices': 'X1,X2,X3,X4,X5',
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
'name,extra_choices',
|
||||||
|
'Choice Set 4,"4A,4B,4C,4D,4E"',
|
||||||
|
'Choice Set 5,"5A,5B,5C,5D,5E"',
|
||||||
|
'Choice Set 6,"6A,6B,6C,6D,6E"',
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
'id,extra_choices',
|
||||||
|
f'{choice_sets[0].pk},"1X,1Y,1Z"',
|
||||||
|
f'{choice_sets[1].pk},"2X,2Y,2Z"',
|
||||||
|
f'{choice_sets[2].pk},"3X,3Y,3Z"',
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.bulk_edit_data = {
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = CustomLink
|
model = CustomLink
|
||||||
|
|
||||||
@ -178,6 +223,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkTestCase(
|
||||||
|
ViewTestCases.DeleteObjectViewTestCase,
|
||||||
|
ViewTestCases.ListObjectsViewTestCase,
|
||||||
|
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||||
|
):
|
||||||
|
model = Bookmark
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
Site(name='Site 4', slug='site-4'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
cls.form_data = {
|
||||||
|
'object_type': site_ct.pk,
|
||||||
|
'object_id': sites[3].pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
sites = Site.objects.all()
|
||||||
|
user = self.user
|
||||||
|
|
||||||
|
bookmarks = (
|
||||||
|
Bookmark(object=sites[0], user=user),
|
||||||
|
Bookmark(object=sites[1], user=user),
|
||||||
|
Bookmark(object=sites[2], user=user),
|
||||||
|
)
|
||||||
|
Bookmark.objects.bulk_create(bookmarks)
|
||||||
|
|
||||||
|
def _get_url(self, action, instance=None):
|
||||||
|
if action == 'list':
|
||||||
|
return reverse('users:bookmarks')
|
||||||
|
return super()._get_url(action, instance)
|
||||||
|
|
||||||
|
def test_list_objects_anonymous(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
def test_list_objects_with_constrained_permission(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = ExportTemplate
|
model = ExportTemplate
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from django.urls import include, path, re_path
|
from django.urls import include, path
|
||||||
|
|
||||||
from extras import views
|
from extras import views
|
||||||
from utilities.urls import get_model_urls
|
from utilities.urls import get_model_urls
|
||||||
@ -15,6 +15,14 @@ urlpatterns = [
|
|||||||
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
|
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
|
||||||
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
|
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
|
||||||
|
|
||||||
|
# Custom field choices
|
||||||
|
path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'),
|
||||||
|
path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'),
|
||||||
|
path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'),
|
||||||
|
path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'),
|
||||||
|
path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'),
|
||||||
|
path('custom-field-choices/<int:pk>/', include(get_model_urls('extras', 'customfieldchoiceset'))),
|
||||||
|
|
||||||
# Custom links
|
# Custom links
|
||||||
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
|
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
|
||||||
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),
|
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),
|
||||||
@ -40,6 +48,11 @@ urlpatterns = [
|
|||||||
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
|
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
|
||||||
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
|
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
|
||||||
|
|
||||||
|
# Bookmarks
|
||||||
|
path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'),
|
||||||
|
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
|
||||||
|
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
|
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
|
||||||
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
|
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
|
||||||
@ -85,6 +98,13 @@ urlpatterns = [
|
|||||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||||
|
|
||||||
|
# Config revisions
|
||||||
|
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
|
||||||
|
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
|
||||||
|
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
|
||||||
|
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
|
||||||
|
path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
|
||||||
|
|
||||||
# Change logging
|
# Change logging
|
||||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||||
@ -114,5 +134,5 @@ urlpatterns = [
|
|||||||
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
|
||||||
]
|
]
|
||||||
|
@ -14,6 +14,7 @@ from core.models import Job
|
|||||||
from core.tables import JobTable
|
from core.tables import JobTable
|
||||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||||
from extras.dashboard.utils import get_widget_class
|
from extras.dashboard.utils import get_widget_class
|
||||||
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.forms import ConfirmationForm, get_field_value
|
from utilities.forms import ConfirmationForm, get_field_value
|
||||||
from utilities.htmx import is_htmx
|
from utilities.htmx import is_htmx
|
||||||
@ -33,7 +34,7 @@ from .scripts import run_script
|
|||||||
#
|
#
|
||||||
|
|
||||||
class CustomFieldListView(generic.ObjectListView):
|
class CustomFieldListView(generic.ObjectListView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
filterset = filtersets.CustomFieldFilterSet
|
filterset = filtersets.CustomFieldFilterSet
|
||||||
filterset_form = forms.CustomFieldFilterForm
|
filterset_form = forms.CustomFieldFilterForm
|
||||||
table = tables.CustomFieldTable
|
table = tables.CustomFieldTable
|
||||||
@ -41,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
|
|||||||
|
|
||||||
@register_model_view(CustomField)
|
@register_model_view(CustomField)
|
||||||
class CustomFieldView(generic.ObjectView):
|
class CustomFieldView(generic.ObjectView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CustomField, 'edit')
|
@register_model_view(CustomField, 'edit')
|
||||||
class CustomFieldEditView(generic.ObjectEditView):
|
class CustomFieldEditView(generic.ObjectEditView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
form = forms.CustomFieldForm
|
form = forms.CustomFieldForm
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CustomField, 'delete')
|
@register_model_view(CustomField, 'delete')
|
||||||
class CustomFieldDeleteView(generic.ObjectDeleteView):
|
class CustomFieldDeleteView(generic.ObjectDeleteView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldBulkImportView(generic.BulkImportView):
|
class CustomFieldBulkImportView(generic.BulkImportView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
model_form = forms.CustomFieldImportForm
|
model_form = forms.CustomFieldImportForm
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldBulkEditView(generic.BulkEditView):
|
class CustomFieldBulkEditView(generic.BulkEditView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
filterset = filtersets.CustomFieldFilterSet
|
filterset = filtersets.CustomFieldFilterSet
|
||||||
table = tables.CustomFieldTable
|
table = tables.CustomFieldTable
|
||||||
form = forms.CustomFieldBulkEditForm
|
form = forms.CustomFieldBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
|
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = CustomField.objects.all()
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
filterset = filtersets.CustomFieldFilterSet
|
filterset = filtersets.CustomFieldFilterSet
|
||||||
table = tables.CustomFieldTable
|
table = tables.CustomFieldTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom field choices
|
||||||
|
#
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetListView(generic.ObjectListView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
filterset = filtersets.CustomFieldChoiceSetFilterSet
|
||||||
|
filterset_form = forms.CustomFieldChoiceSetFilterForm
|
||||||
|
table = tables.CustomFieldChoiceSetTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CustomFieldChoiceSet)
|
||||||
|
class CustomFieldChoiceSetView(generic.ObjectView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CustomFieldChoiceSet, 'edit')
|
||||||
|
class CustomFieldChoiceSetEditView(generic.ObjectEditView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
form = forms.CustomFieldChoiceSetForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CustomFieldChoiceSet, 'delete')
|
||||||
|
class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
model_form = forms.CustomFieldChoiceSetImportForm
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
filterset = filtersets.CustomFieldChoiceSetFilterSet
|
||||||
|
table = tables.CustomFieldChoiceSetTable
|
||||||
|
form = forms.CustomFieldChoiceSetBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = CustomFieldChoiceSet.objects.all()
|
||||||
|
filterset = filtersets.CustomFieldChoiceSetFilterSet
|
||||||
|
table = tables.CustomFieldChoiceSetTable
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Custom links
|
# Custom links
|
||||||
#
|
#
|
||||||
@ -236,6 +282,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
|
|||||||
table = tables.SavedFilterTable
|
table = tables.SavedFilterTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Bookmarks
|
||||||
|
#
|
||||||
|
|
||||||
|
class BookmarkCreateView(generic.ObjectEditView):
|
||||||
|
form = forms.BookmarkForm
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||||
|
obj.user = request.user
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Bookmark, 'delete')
|
||||||
|
class BookmarkDeleteView(generic.ObjectDeleteView):
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
table = tables.BookmarkTable
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return Bookmark.objects.filter(user=request.user)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Webhooks
|
# Webhooks
|
||||||
#
|
#
|
||||||
@ -511,7 +586,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ObjectChangeListView(generic.ObjectListView):
|
class ObjectChangeListView(generic.ObjectListView):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.valid_models()
|
||||||
filterset = filtersets.ObjectChangeFilterSet
|
filterset = filtersets.ObjectChangeFilterSet
|
||||||
filterset_form = forms.ObjectChangeFilterForm
|
filterset_form = forms.ObjectChangeFilterForm
|
||||||
table = tables.ObjectChangeTable
|
table = tables.ObjectChangeTable
|
||||||
@ -521,10 +596,10 @@ class ObjectChangeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
@register_model_view(ObjectChange)
|
@register_model_view(ObjectChange)
|
||||||
class ObjectChangeView(generic.ObjectView):
|
class ObjectChangeView(generic.ObjectView):
|
||||||
queryset = ObjectChange.objects.all()
|
queryset = ObjectChange.objects.valid_models()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
|
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
request_id=instance.request_id
|
request_id=instance.request_id
|
||||||
).exclude(
|
).exclude(
|
||||||
pk=instance.pk
|
pk=instance.pk
|
||||||
@ -534,7 +609,7 @@ class ObjectChangeView(generic.ObjectView):
|
|||||||
orderable=False
|
orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
|
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||||
changed_object_type=instance.changed_object_type,
|
changed_object_type=instance.changed_object_type,
|
||||||
changed_object_id=instance.changed_object_id,
|
changed_object_id=instance.changed_object_id,
|
||||||
)
|
)
|
||||||
@ -1176,6 +1251,74 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Config Revisions
|
||||||
|
#
|
||||||
|
|
||||||
|
class ConfigRevisionListView(generic.ObjectListView):
|
||||||
|
queryset = ConfigRevision.objects.all()
|
||||||
|
filterset = filtersets.ConfigRevisionFilterSet
|
||||||
|
filterset_form = forms.ConfigRevisionFilterForm
|
||||||
|
table = tables.ConfigRevisionTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConfigRevision)
|
||||||
|
class ConfigRevisionView(generic.ObjectView):
|
||||||
|
queryset = ConfigRevision.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionEditView(generic.ObjectEditView):
|
||||||
|
queryset = ConfigRevision.objects.all()
|
||||||
|
form = forms.ConfigRevisionForm
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConfigRevision, 'delete')
|
||||||
|
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = ConfigRevision.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = ConfigRevision.objects.all()
|
||||||
|
filterset = filtersets.ConfigRevisionFilterSet
|
||||||
|
table = tables.ConfigRevisionTable
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'extras.configrevision_edit'
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||||
|
|
||||||
|
# Get the current ConfigRevision
|
||||||
|
config_version = get_config().version
|
||||||
|
current_config = ConfigRevision.objects.filter(pk=config_version).first()
|
||||||
|
|
||||||
|
params = []
|
||||||
|
for param in PARAMS:
|
||||||
|
params.append((
|
||||||
|
param.name,
|
||||||
|
current_config.data.get(param.name, None),
|
||||||
|
candidate_config.data.get(param.name, None)
|
||||||
|
))
|
||||||
|
|
||||||
|
return render(request, 'extras/configrevision_restore.html', {
|
||||||
|
'object': candidate_config,
|
||||||
|
'params': params,
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
if not request.user.has_perm('extras.configrevision_edit'):
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||||
|
candidate_config.activate()
|
||||||
|
messages.success(request, f"Restored configuration revision #{pk}")
|
||||||
|
|
||||||
|
return redirect(candidate_config.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Markdown
|
# Markdown
|
||||||
#
|
#
|
||||||
|
@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer):
|
|||||||
Representation of an ASN which does not exist in the database.
|
Representation of an ASN which does not exist in the database.
|
||||||
"""
|
"""
|
||||||
asn = serializers.IntegerField(read_only=True)
|
asn = serializers.IntegerField(read_only=True)
|
||||||
|
description = serializers.CharField(required=False)
|
||||||
|
|
||||||
def to_representation(self, asn):
|
def to_representation(self, asn):
|
||||||
rir = NestedRIRSerializer(self.context['range'].rir, context={
|
rir = NestedRIRSerializer(self.context['range'].rir, context={
|
||||||
@ -218,12 +219,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
|||||||
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
||||||
scope = serializers.SerializerMethodField(read_only=True)
|
scope = serializers.SerializerMethodField(read_only=True)
|
||||||
vlan_count = serializers.IntegerField(read_only=True)
|
vlan_count = serializers.IntegerField(read_only=True)
|
||||||
|
utilization = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VLANGroup
|
model = VLANGroup
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
||||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
|
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
|
||||||
]
|
]
|
||||||
validators = []
|
validators = []
|
||||||
|
|
||||||
@ -432,6 +434,7 @@ class AvailableIPSerializer(serializers.Serializer):
|
|||||||
family = serializers.IntegerField(read_only=True)
|
family = serializers.IntegerField(read_only=True)
|
||||||
address = serializers.CharField(read_only=True)
|
address = serializers.CharField(read_only=True)
|
||||||
vrf = NestedVRFSerializer(read_only=True)
|
vrf = NestedVRFSerializer(read_only=True)
|
||||||
|
description = serializers.CharField(required=False)
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
if self.context.get('vrf'):
|
if self.context.get('vrf'):
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
|
from django.db.models.functions import Round
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django_pglocks import advisory_lock
|
from django_pglocks import advisory_lock
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from netaddr import IPSet
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import APIRootView
|
from rest_framework.routers import APIRootView
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@ -12,10 +16,12 @@ from circuits.models import Provider
|
|||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from ipam import filtersets
|
from ipam import filtersets
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
|
from ipam.utils import get_next_available_prefix
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||||
|
from utilities.api import get_serializer_for_model
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from . import serializers
|
from . import serializers
|
||||||
from ipam.models import L2VPN, L2VPNTermination
|
from ipam.models import L2VPN, L2VPNTermination
|
||||||
@ -145,9 +151,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class VLANGroupViewSet(NetBoxModelViewSet):
|
class VLANGroupViewSet(NetBoxModelViewSet):
|
||||||
queryset = VLANGroup.objects.annotate(
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
vlan_count=count_related(VLAN, 'group')
|
|
||||||
).prefetch_related('tags')
|
|
||||||
serializer_class = serializers.VLANGroupSerializer
|
serializer_class = serializers.VLANGroupSerializer
|
||||||
filterset_class = filtersets.VLANGroupFilterSet
|
filterset_class = filtersets.VLANGroupFilterSet
|
||||||
|
|
||||||
@ -207,237 +211,233 @@ def get_results_limit(request):
|
|||||||
return limit
|
return limit
|
||||||
|
|
||||||
|
|
||||||
class AvailableASNsView(ObjectValidationMixin, APIView):
|
class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||||
queryset = ASN.objects.all()
|
"""
|
||||||
|
Return a list of dicts representing child objects that have not yet been created for a parent object.
|
||||||
|
"""
|
||||||
|
read_serializer_class = None
|
||||||
|
write_serializer_class = None
|
||||||
|
advisory_lock_key = None
|
||||||
|
|
||||||
|
def get_parent(self, request, pk):
|
||||||
|
"""
|
||||||
|
Return the parent object.
|
||||||
|
"""
|
||||||
|
raise NotImplemented()
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
"""
|
||||||
|
Return all available objects for the parent.
|
||||||
|
"""
|
||||||
|
raise NotImplemented()
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
"""
|
||||||
|
Return any extra context data for the serializer.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def check_sufficient_available(self, requested_objects, available_objects):
|
||||||
|
"""
|
||||||
|
Check if there exist a sufficient number of available objects to satisfy the request.
|
||||||
|
"""
|
||||||
|
return len(requested_objects) <= len(available_objects)
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
"""
|
||||||
|
Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID)
|
||||||
|
on the request data.
|
||||||
|
"""
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
|
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
parent = self.get_parent(request, pk)
|
||||||
limit = get_results_limit(request)
|
limit = get_results_limit(request)
|
||||||
|
available_objects = self.get_available_objects(parent, limit)
|
||||||
|
|
||||||
available_asns = asnrange.get_available_asns()[:limit]
|
serializer = self.read_serializer_class(available_objects, many=True, context={
|
||||||
|
|
||||||
serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={
|
|
||||||
'request': request,
|
'request': request,
|
||||||
'range': asnrange,
|
**self.get_extra_context(parent),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)})
|
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||||
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
parent = self.get_parent(request, pk)
|
||||||
|
|
||||||
# Normalize to a list of objects
|
# Normalize request data to a list of objects
|
||||||
requested_asns = request.data if isinstance(request.data, list) else [request.data]
|
requested_objects = request.data if isinstance(request.data, list) else [request.data]
|
||||||
|
|
||||||
# Determine if the requested number of IPs is available
|
# Serialize and validate the request data
|
||||||
available_asns = asnrange.get_available_asns()
|
serializer = self.write_serializer_class(data=requested_objects, many=True, context={
|
||||||
if len(available_asns) < len(requested_asns):
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"detail": f"An insufficient number of ASNs are available within {asnrange} "
|
|
||||||
f"({len(requested_asns)} requested, {len(available_asns)} available)"
|
|
||||||
},
|
|
||||||
status=status.HTTP_409_CONFLICT
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign ASNs from the list of available IPs and copy VRF assignment from the parent
|
|
||||||
for i, requested_asn in enumerate(requested_asns):
|
|
||||||
requested_asn.update({
|
|
||||||
'rir': asnrange.rir.pk,
|
|
||||||
'range': asnrange.pk,
|
|
||||||
'asn': available_asns[i],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
|
||||||
context = {'request': request}
|
|
||||||
if isinstance(request.data, list):
|
|
||||||
serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context)
|
|
||||||
else:
|
|
||||||
serializer = serializers.ASNSerializer(data=requested_asns[0], context=context)
|
|
||||||
|
|
||||||
# Create the new IP address(es)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
created = serializer.save()
|
|
||||||
self._validate_objects(created)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise PermissionDenied()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailableASNSerializer
|
|
||||||
|
|
||||||
return serializers.ASNSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class AvailablePrefixesView(ObjectValidationMixin, APIView):
|
|
||||||
queryset = Prefix.objects.all()
|
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
|
||||||
def get(self, request, pk):
|
|
||||||
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
|
||||||
available_prefixes = prefix.get_available_prefixes()
|
|
||||||
|
|
||||||
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
|
||||||
'request': request,
|
'request': request,
|
||||||
'vrf': prefix.vrf,
|
**self.get_extra_context(parent),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
|
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
|
||||||
def post(self, request, pk):
|
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
|
||||||
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
|
||||||
available_prefixes = prefix.get_available_prefixes()
|
|
||||||
|
|
||||||
# Validate Requested Prefixes' length
|
|
||||||
serializer = serializers.PrefixLengthSerializer(
|
|
||||||
data=request.data if isinstance(request.data, list) else [request.data],
|
|
||||||
many=True,
|
|
||||||
context={
|
|
||||||
'request': request,
|
|
||||||
'prefix': prefix,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors,
|
serializer.errors,
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
requested_prefixes = serializer.validated_data
|
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
|
||||||
# Allocate prefixes to the requested objects based on availability within the parent
|
available_objects = self.get_available_objects(parent)
|
||||||
for i, requested_prefix in enumerate(requested_prefixes):
|
|
||||||
|
|
||||||
# Find the first available prefix equal to or larger than the requested size
|
# Determine if the requested number of objects is available
|
||||||
for available_prefix in available_prefixes.iter_cidrs():
|
if not self.check_sufficient_available(serializer.validated_data, available_objects):
|
||||||
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
|
||||||
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
|
||||||
requested_prefix['prefix'] = allocated_prefix
|
|
||||||
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{"detail": f"Insufficient resources are available to satisfy the request"},
|
||||||
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
|
||||||
},
|
|
||||||
status=status.HTTP_409_CONFLICT
|
status=status.HTTP_409_CONFLICT
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove the allocated prefix from the list of available prefixes
|
# Prepare object data for deserialization
|
||||||
available_prefixes.remove(allocated_prefix)
|
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
# Initialize the serializer with a list or a single object depending on what was requested
|
||||||
context = {'request': request}
|
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||||
if isinstance(request.data, list):
|
context = {'request': request}
|
||||||
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
|
if isinstance(request.data, list):
|
||||||
else:
|
serializer = serializer_class(data=requested_objects, many=True, context=context)
|
||||||
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
|
else:
|
||||||
|
serializer = serializer_class(data=requested_objects[0], context=context)
|
||||||
|
|
||||||
# Create the new Prefix(es)
|
if not serializer.is_valid():
|
||||||
if serializer.is_valid():
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Create the new IP address(es)
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
created = serializer.save()
|
created = serializer.save()
|
||||||
self._validate_objects(created)
|
self._validate_objects(created)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailablePrefixSerializer
|
|
||||||
|
|
||||||
return serializers.PrefixLengthSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
|
class AvailableASNsView(AvailableObjectsView):
|
||||||
queryset = IPAddress.objects.all()
|
queryset = ASN.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailableASNSerializer
|
||||||
|
write_serializer_class = serializers.AvailableASNSerializer
|
||||||
|
advisory_lock_key = 'available-asns'
|
||||||
|
|
||||||
def get_parent(self, request, pk):
|
def get_parent(self, request, pk):
|
||||||
raise NotImplemented()
|
return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_asns()[:limit]
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'range': parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'rir': parent.rir.pk,
|
||||||
|
'range': parent.pk,
|
||||||
|
'asn': available_objects[i],
|
||||||
|
})
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
parent = self.get_parent(request, pk)
|
return super().get(request, pk)
|
||||||
limit = get_results_limit(request)
|
|
||||||
|
|
||||||
|
@extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)})
|
||||||
|
def post(self, request, pk):
|
||||||
|
return super().post(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailablePrefixesView(AvailableObjectsView):
|
||||||
|
queryset = Prefix.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailablePrefixSerializer
|
||||||
|
write_serializer_class = serializers.PrefixLengthSerializer
|
||||||
|
advisory_lock_key = 'available-prefixes'
|
||||||
|
|
||||||
|
def get_parent(self, request, pk):
|
||||||
|
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_prefixes().iter_cidrs()
|
||||||
|
|
||||||
|
def check_sufficient_available(self, requested_objects, available_objects):
|
||||||
|
available_prefixes = IPSet(available_objects)
|
||||||
|
for requested_object in requested_objects:
|
||||||
|
if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'prefix': parent,
|
||||||
|
'vrf': parent.vrf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
available_prefixes = IPSet(available_objects)
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
|
||||||
|
# Find the first available prefix equal to or larger than the requested size
|
||||||
|
if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']):
|
||||||
|
request_data.update({
|
||||||
|
'prefix': allocated_prefix,
|
||||||
|
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||||
|
def get(self, request, pk):
|
||||||
|
return super().get(request, pk)
|
||||||
|
|
||||||
|
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
|
||||||
|
def post(self, request, pk):
|
||||||
|
return super().post(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableIPAddressesView(AvailableObjectsView):
|
||||||
|
queryset = IPAddress.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailableIPSerializer
|
||||||
|
write_serializer_class = serializers.AvailableIPSerializer
|
||||||
|
advisory_lock_key = 'available-ips'
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
# Calculate available IPs within the parent
|
# Calculate available IPs within the parent
|
||||||
ip_list = []
|
ip_list = []
|
||||||
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
||||||
ip_list.append(ip)
|
ip_list.append(ip)
|
||||||
if index == limit:
|
if index == limit:
|
||||||
break
|
break
|
||||||
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
|
return ip_list
|
||||||
'request': request,
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
'parent': parent,
|
'parent': parent,
|
||||||
'vrf': parent.vrf,
|
'vrf': parent.vrf,
|
||||||
})
|
}
|
||||||
|
|
||||||
return Response(serializer.data)
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
available_ips = iter(available_objects)
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'address': f'{next(available_ips)}/{parent.mask_length}',
|
||||||
|
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||||
|
def get(self, request, pk):
|
||||||
|
return super().get(request, pk)
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
return super().post(request, pk)
|
||||||
parent = self.get_parent(request, pk)
|
|
||||||
|
|
||||||
# Normalize to a list of objects
|
|
||||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
|
||||||
|
|
||||||
# Determine if the requested number of IPs is available
|
|
||||||
available_ips = parent.get_available_ips()
|
|
||||||
if available_ips.size < len(requested_ips):
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"detail": f"An insufficient number of IP addresses are available within {parent} "
|
|
||||||
f"({len(requested_ips)} requested, {len(available_ips)} available)"
|
|
||||||
},
|
|
||||||
status=status.HTTP_409_CONFLICT
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
|
|
||||||
available_ips = iter(available_ips)
|
|
||||||
for requested_ip in requested_ips:
|
|
||||||
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
|
|
||||||
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
|
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
|
||||||
context = {'request': request}
|
|
||||||
if isinstance(request.data, list):
|
|
||||||
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
|
|
||||||
else:
|
|
||||||
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
|
|
||||||
|
|
||||||
# Create the new IP address(es)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
created = serializer.save()
|
|
||||||
self._validate_objects(created)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise PermissionDenied()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailableIPSerializer
|
|
||||||
|
|
||||||
return serializers.IPAddressSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
|
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
|
||||||
@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
|
|||||||
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
|
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
|
||||||
class AvailableVLANsView(ObjectValidationMixin, APIView):
|
class AvailableVLANsView(AvailableObjectsView):
|
||||||
queryset = VLAN.objects.all()
|
queryset = VLAN.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailableVLANSerializer
|
||||||
|
write_serializer_class = serializers.CreateAvailableVLANSerializer
|
||||||
|
advisory_lock_key = 'available-vlans'
|
||||||
|
|
||||||
|
def get_parent(self, request, pk):
|
||||||
|
return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_vids()[:limit]
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'group': parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'vid': available_objects.pop(0),
|
||||||
|
'group': parent.pk,
|
||||||
|
})
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
return super().get(request, pk)
|
||||||
limit = get_results_limit(request)
|
|
||||||
|
|
||||||
available_vlans = vlangroup.get_available_vids()[:limit]
|
|
||||||
serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
|
|
||||||
'request': request,
|
|
||||||
'group': vlangroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
return super().post(request, pk)
|
||||||
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
|
||||||
available_vlans = vlangroup.get_available_vids()
|
|
||||||
many = isinstance(request.data, list)
|
|
||||||
|
|
||||||
# Validate requested VLANs
|
|
||||||
serializer = serializers.CreateAvailableVLANSerializer(
|
|
||||||
data=request.data if many else [request.data],
|
|
||||||
many=True,
|
|
||||||
context={
|
|
||||||
'request': request,
|
|
||||||
'group': vlangroup,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not serializer.is_valid():
|
|
||||||
return Response(
|
|
||||||
serializer.errors,
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_vlans = serializer.validated_data
|
|
||||||
|
|
||||||
for i, requested_vlan in enumerate(requested_vlans):
|
|
||||||
try:
|
|
||||||
requested_vlan['vid'] = available_vlans.pop(0)
|
|
||||||
requested_vlan['group'] = vlangroup.pk
|
|
||||||
except IndexError:
|
|
||||||
return Response({
|
|
||||||
"detail": "The requested number of VLANs is not available"
|
|
||||||
}, status=status.HTTP_409_CONFLICT)
|
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
|
||||||
context = {'request': request}
|
|
||||||
if many:
|
|
||||||
serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context)
|
|
||||||
else:
|
|
||||||
serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context)
|
|
||||||
|
|
||||||
# Create the new VLAN(s)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
created = serializer.save()
|
|
||||||
self._validate_objects(created)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise PermissionDenied()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailableVLANSerializer
|
|
||||||
|
|
||||||
return serializers.VLANSerializer
|
|
||||||
|
@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
interface = self.instance.assigned_object
|
interface = self.instance.assigned_object
|
||||||
if type(interface) in (Interface, VMInterface):
|
if type(interface) in (Interface, VMInterface):
|
||||||
parent = interface.parent_object
|
parent = interface.parent_object
|
||||||
|
parent.snapshot()
|
||||||
if self.cleaned_data['primary_for_parent']:
|
if self.cleaned_data['primary_for_parent']:
|
||||||
if ipaddress.address.version == 4:
|
if ipaddress.address.version == 4:
|
||||||
parent.primary_ip4 = ipaddress
|
parent.primary_ip4 = ipaddress
|
||||||
|
@ -4,6 +4,7 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from ipam.fields import ASNField
|
from ipam.fields import ASNField
|
||||||
|
from ipam.querysets import ASNRangeQuerySet
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = ASNRangeQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
verbose_name = 'ASN range'
|
verbose_name = 'ASN range'
|
||||||
|
@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
|||||||
Return all available IPs within this prefix as an IPSet.
|
Return all available IPs within this prefix as an IPSet.
|
||||||
"""
|
"""
|
||||||
if self.mark_utilized:
|
if self.mark_utilized:
|
||||||
return list()
|
return netaddr.IPSet()
|
||||||
|
|
||||||
prefix = netaddr.IPSet(self.prefix)
|
prefix = netaddr.IPSet(self.prefix)
|
||||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||||
|
@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
|
|||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.querysets import VLANQuerySet
|
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
from virtualization.models import VMInterface
|
from virtualization.models import VMInterface
|
||||||
|
|
||||||
@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
|
|||||||
help_text=_('Highest permissible ID of a child VLAN')
|
help_text=_('Highest permissible ID of a child VLAN')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = VLANGroupQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name', 'pk') # Name may be non-unique
|
ordering = ('name', 'pk') # Name may be non-unique
|
||||||
constraints = (
|
constraints = (
|
||||||
|
@ -1,8 +1,34 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
|
from django.db.models.functions import Round
|
||||||
|
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
from utilities.utils import count_related
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ASNRangeQuerySet',
|
||||||
|
'PrefixQuerySet',
|
||||||
|
'VLANQuerySet',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ASNRangeQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def annotate_asn_counts(self):
|
||||||
|
"""
|
||||||
|
Annotate the number of ASNs which appear within each range.
|
||||||
|
"""
|
||||||
|
from .models import ASN
|
||||||
|
|
||||||
|
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
|
||||||
|
# that we can use to count ASNs and return a single value per ASNRange.
|
||||||
|
asns = ASN.objects.filter(
|
||||||
|
asn__gte=OuterRef('start'),
|
||||||
|
asn__lte=OuterRef('end')
|
||||||
|
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
|
||||||
|
|
||||||
|
return self.annotate(asn_count=Subquery(asns))
|
||||||
|
|
||||||
|
|
||||||
class PrefixQuerySet(RestrictedQuerySet):
|
class PrefixQuerySet(RestrictedQuerySet):
|
||||||
@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VLANGroupQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
|
def annotate_utilization(self):
|
||||||
|
from .models import VLAN
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
vlan_count=count_related(VLAN, 'group'),
|
||||||
|
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VLANQuerySet(RestrictedQuerySet):
|
class VLANQuerySet(RestrictedQuerySet):
|
||||||
|
|
||||||
def get_for_device(self, device):
|
def get_for_device(self, device):
|
||||||
|
@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:asnrange_list'
|
url_name='ipam:asnrange_list'
|
||||||
)
|
)
|
||||||
asn_count = columns.LinkedCountColumn(
|
asn_count = tables.Column(
|
||||||
viewname='ipam:asn_list',
|
verbose_name=_('ASNs')
|
||||||
url_params={'asn_id': 'pk'},
|
|
||||||
verbose_name=_('ASN Count')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
verbose_name=_('Provider Count')
|
verbose_name=_('Provider Count')
|
||||||
)
|
)
|
||||||
sites = columns.ManyToManyColumn(
|
sites = columns.ManyToManyColumn(
|
||||||
linkify_item=True
|
linkify_item=True,
|
||||||
|
verbose_name=_('Sites')
|
||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
|
@ -19,14 +19,22 @@ __all__ = (
|
|||||||
|
|
||||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||||
|
|
||||||
|
AGGREGATE_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="aggregate_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
PREFIX_LINK = """
|
PREFIX_LINK = """
|
||||||
{% if record.pk %}
|
{% if record.pk %}
|
||||||
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
|
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PREFIX_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="prefix_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
PREFIX_LINK_WITH_DEPTH = """
|
PREFIX_LINK_WITH_DEPTH = """
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% if record.depth %}
|
{% if record.depth %}
|
||||||
@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
|
|||||||
|
|
||||||
IPADDRESS_LINK = """
|
IPADDRESS_LINK = """
|
||||||
{% if record.pk %}
|
{% if record.pk %}
|
||||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
|
||||||
{% elif perms.ipam.add_ipaddress %}
|
{% elif perms.ipam.add_ipaddress %}
|
||||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -48,6 +56,10 @@ IPADDRESS_LINK = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
IPADDRESS_COPY_BUTTON = """
|
||||||
|
{% copy_content record.pk prefix="ipaddress_" %}
|
||||||
|
"""
|
||||||
|
|
||||||
IPADDRESS_ASSIGN_LINK = """
|
IPADDRESS_ASSIGN_LINK = """
|
||||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||||
"""
|
"""
|
||||||
@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
|
|||||||
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
||||||
prefix = tables.Column(
|
prefix = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='Aggregate'
|
verbose_name='Aggregate',
|
||||||
|
attrs={
|
||||||
|
# Allow the aggregate to be copied to the clipboard
|
||||||
|
'a': {'id': lambda record: f"aggregate_{record.pk}"}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
date_added = tables.DateColumn(
|
date_added = tables.DateColumn(
|
||||||
format="Y-m-d",
|
format="Y-m-d",
|
||||||
@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:aggregate_list'
|
url_name='ipam:aggregate_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=AGGREGATE_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:prefix_list'
|
url_name='ipam:prefix_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=PREFIX_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:ipaddress_list'
|
url_name='ipam:ipaddress_list'
|
||||||
)
|
)
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
extra_buttons=IPADDRESS_COPY_BUTTON
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user