mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Compare commits
96 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b88b5b0b1b | ||
![]() |
6eeb382512 | ||
![]() |
e5d6c71171 | ||
![]() |
f777bfee2e | ||
![]() |
8b63eb64c1 | ||
![]() |
cff29f9551 | ||
![]() |
a5c0cae112 | ||
![]() |
2a27e475e4 | ||
![]() |
44efa037cc | ||
![]() |
6c17629159 | ||
![]() |
f13d028c98 | ||
![]() |
f5d32b1bf1 | ||
![]() |
f05897d61a | ||
![]() |
b5421f1cd6 | ||
![]() |
23cc4f1c41 | ||
![]() |
9c2cd66162 | ||
![]() |
f61a2964c8 | ||
![]() |
ee94fb0b94 | ||
![]() |
8fb8f4c75b | ||
![]() |
e33793dc82 | ||
![]() |
3b8841ee3b | ||
![]() |
ea4c205a37 | ||
![]() |
2a5d3abafb | ||
![]() |
71e6ea5785 | ||
![]() |
0a9887b42f | ||
![]() |
3ecf29d797 | ||
![]() |
c48e4f590e | ||
![]() |
aee83a434a | ||
![]() |
a17699d261 | ||
![]() |
f97d07a11c | ||
![]() |
1fd3d390ae | ||
![]() |
7dab7d730d | ||
![]() |
c660f1c019 | ||
![]() |
334b45f55a | ||
![]() |
e6c1cebd34 | ||
![]() |
a9af541e81 | ||
![]() |
f706572113 | ||
![]() |
6a6286777c | ||
![]() |
afeddee10d | ||
![]() |
a48bee2a2e | ||
![]() |
b9db6ebd63 | ||
![]() |
9e0493c64c | ||
![]() |
e3509c092a | ||
![]() |
762cfc7d10 | ||
![]() |
522f80ed9d | ||
![]() |
fd6062de75 | ||
![]() |
c872cce59f | ||
![]() |
dc8267d890 | ||
![]() |
2bfb9f4ed0 | ||
![]() |
dda0a55e5e | ||
![]() |
2680f855ff | ||
![]() |
6ca791850a | ||
![]() |
43df06f210 | ||
![]() |
7e6b1bbd79 | ||
![]() |
0e68901022 | ||
![]() |
179c06ec20 | ||
![]() |
bd8cf64ded | ||
![]() |
67b42710ef | ||
![]() |
67d62a2089 | ||
![]() |
e24fa2ee4d | ||
![]() |
5fe5b2e7c4 | ||
![]() |
d68f42140f | ||
![]() |
95d0ca56a7 | ||
![]() |
716acaa657 | ||
![]() |
ecb8656723 | ||
![]() |
065511fca2 | ||
![]() |
77f0eeb7bf | ||
![]() |
f45b671fc9 | ||
![]() |
b1cbdbe079 | ||
![]() |
e5e7a66cb9 | ||
![]() |
357ae44cde | ||
![]() |
b62f2347c5 | ||
![]() |
0c6726d40f | ||
![]() |
cc099e86e1 | ||
![]() |
a97b438b7e | ||
![]() |
d7672ab260 | ||
![]() |
b3d318cbe1 | ||
![]() |
2804359cdd | ||
![]() |
e8d08c4d38 | ||
![]() |
98d9e7f8d5 | ||
![]() |
51d046b1f5 | ||
![]() |
88565e8f68 | ||
![]() |
a2a8779ebc | ||
![]() |
03ff535772 | ||
![]() |
e6d364b250 | ||
![]() |
be07f222f6 | ||
![]() |
21f5fe873c | ||
![]() |
83dc65acb5 | ||
![]() |
b6c8502408 | ||
![]() |
4795fab16f | ||
![]() |
de2e2b5c82 | ||
![]() |
cf7ab43f39 | ||
![]() |
1700a9265c | ||
![]() |
39b03abe72 | ||
![]() |
b497b85665 | ||
![]() |
0d29e5776c |
@ -15,7 +15,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: v4.3.1
|
placeholder: v4.3.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,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: v4.3.1
|
placeholder: v4.3.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<h3>
|
<h3>
|
||||||
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> ·
|
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> ·
|
||||||
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> ·
|
:briefcase: <a href="#briefcase-looking-for-a-job">Work with us!</a> ·
|
||||||
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
|
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -109,21 +109,9 @@ Do you have an idea for something you'd like to build in NetBox, but might not b
|
|||||||
|
|
||||||
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
|
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
|
||||||
|
|
||||||
## :rescue_worker_helmet: Become a Maintainer
|
## :briefcase: Looking for a Job?
|
||||||
|
|
||||||
We're always looking for motivated individuals to join the maintainers team and help drive NetBox's long-term development. Some of our most sought-after skills include:
|
At [NetBox Labs](https://netboxlabs.com/), we're always looking for highly skilled and motivated people to join our team. While NetBox is a core part of our product lineup, we have an ever-expanding suite of solutions serving the network automation space. Check out our [current openings](https://netboxlabs.com/careers/) to see if you might be a fit!
|
||||||
|
|
||||||
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
|
|
||||||
* Expertise working with PostgreSQL databases
|
|
||||||
* Javascript & TypeScript proficiency
|
|
||||||
* A knack for web application design (HTML & CSS)
|
|
||||||
* Familiarity with git and software development best practices
|
|
||||||
* Excellent attention to detail
|
|
||||||
* Working experience in the field of network operations & engineering
|
|
||||||
|
|
||||||
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
|
|
||||||
|
|
||||||
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
|
|
||||||
|
|
||||||
## :heart: Other Ways to Contribute
|
## :heart: Other Ways to Contribute
|
||||||
|
|
||||||
|
@ -6,9 +6,9 @@
|
|||||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
|
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||||
<p>
|
<p>
|
||||||
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
|
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||||
<strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
|
<strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
|
||||||
</p>
|
</p>
|
||||||
|
@ -14,6 +14,10 @@ django-debug-toolbar
|
|||||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||||
django-filter
|
django-filter
|
||||||
|
|
||||||
|
# Django Debug Toolbar extension for GraphiQL
|
||||||
|
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
|
||||||
|
django-graphiql-debug-toolbar
|
||||||
|
|
||||||
# HTMX utilities for Django
|
# HTMX utilities for Django
|
||||||
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
||||||
django-htmx
|
django-htmx
|
||||||
@ -108,6 +112,7 @@ nh3
|
|||||||
|
|
||||||
# Fork of PIL (Python Imaging Library) for image processing
|
# Fork of PIL (Python Imaging Library) for image processing
|
||||||
# https://github.com/python-pillow/Pillow/releases
|
# https://github.com/python-pillow/Pillow/releases
|
||||||
|
# https://pillow.readthedocs.io/en/stable/releasenotes/
|
||||||
Pillow
|
Pillow
|
||||||
|
|
||||||
# PostgreSQL database adapter for Python
|
# PostgreSQL database adapter for Python
|
||||||
@ -126,21 +131,22 @@ requests
|
|||||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||||
rq
|
rq
|
||||||
|
|
||||||
# Social authentication framework
|
|
||||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
|
||||||
social-auth-core
|
|
||||||
|
|
||||||
# Django app for social-auth-core
|
# Django app for social-auth-core
|
||||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||||
social-auth-app-django
|
social-auth-app-django
|
||||||
|
|
||||||
|
# Social authentication framework
|
||||||
|
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||||
|
social-auth-core
|
||||||
|
|
||||||
# Strawberry GraphQL
|
# Strawberry GraphQL
|
||||||
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
||||||
strawberry-graphql
|
strawberry-graphql
|
||||||
|
|
||||||
# Strawberry GraphQL Django extension
|
# Strawberry GraphQL Django extension
|
||||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||||
strawberry-graphql-django
|
# See #19771
|
||||||
|
strawberry-graphql-django==0.60.0
|
||||||
|
|
||||||
# SVG image rendering (used for rack elevations)
|
# SVG image rendering (used for rack elevations)
|
||||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Default: `None`
|
Default: `None`
|
||||||
|
|
||||||
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
|
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be `True` for this parameter to take effect. For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||||
@ -16,7 +16,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
The `sentry-sdk` Python package is required to enable Sentry integration.
|
The `sentry-sdk` Python package is required to enable Sentry integration.
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Default: `True`
|
Default: `True`
|
||||||
|
|
||||||
Setting this to False will disable the GraphQL API.
|
Setting this to `False` will disable the GraphQL API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -57,7 +57,7 @@ Sets content for the top banner in the user interface.
|
|||||||
|
|
||||||
Default: `True`
|
Default: `True`
|
||||||
|
|
||||||
Enables anonymous census reporting. To opt out of census reporting, set this to False.
|
Enables anonymous census reporting. To opt out of census reporting, set this to `False`.
|
||||||
|
|
||||||
This data enables the project maintainers to estimate how many NetBox deployments exist and track the adoption of new versions over time. Census reporting effects a single HTTP request each time a worker starts. The only data reported by this function are the NetBox version, Python version, and a pseudorandom unique identifier.
|
This data enables the project maintainers to estimate how many NetBox deployments exist and track the adoption of new versions over time. Census reporting effects a single HTTP request each time a worker starts. The only data reported by this function are the NetBox version, Python version, and a pseudorandom unique identifier.
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
|
|||||||
|
|
||||||
Default: `True`
|
Default: `True`
|
||||||
|
|
||||||
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False.
|
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to `False`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -143,7 +143,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
|
Setting this to `True` will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -181,7 +181,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
|
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to `True` to prefer IPv4 instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Remote Authentication Settings
|
# Remote Authentication Settings
|
||||||
|
|
||||||
The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be true in order for these settings to take effect.
|
The configuration parameters listed here control remote authentication for NetBox. Note that `REMOTE_AUTH_ENABLED` must be `True` in order for these settings to take effect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -8,7 +8,7 @@ The configuration parameters listed here control remote authentication for NetBo
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
|
If `True`, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_G
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
|
If `True`, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ The list of groups to assign a new user account when created using remote authen
|
|||||||
|
|
||||||
Default: `{}` (Empty dictionary)
|
Default: `{}` (Empty dictionary)
|
||||||
|
|
||||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
|
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as `True` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as `False`.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## ALLOWED_HOSTS
|
## ALLOWED_HOSTS
|
||||||
|
|
||||||
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attacks](https://docs.djangoproject.com/en/stable/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
This parameter must always be defined as a list or tuple, even if only a single value is provided.
|
||||||
|
|
||||||
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
|
The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to `True`, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
The default value of this parameter changed from true to false in NetBox v4.3.0.
|
The default value of this parameter changed from `True` to `False` in NetBox v4.3.0.
|
||||||
|
|
||||||
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ Although it is not recommended, the default validation rules can be disabled by
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
|
If `True`, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all
|
|||||||
|
|
||||||
These settings specify a list of origins that are authorized to make cross-site API requests. Use
|
These settings specify a list of origins that are authorized to make cross-site API requests. Use
|
||||||
`CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular
|
`CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular
|
||||||
expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example:
|
expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is `True`.) For example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
CORS_ORIGIN_WHITELIST = [
|
CORS_ORIGIN_WHITELIST = [
|
||||||
@ -84,7 +84,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
If `True`, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ If true, the cookie employed for cross-site request forgery (CSRF) protection wi
|
|||||||
|
|
||||||
Default: `[]`
|
Default: `[]`
|
||||||
|
|
||||||
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
|
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
CSRF_TRUSTED_ORIGINS = (
|
CSRF_TRUSTED_ORIGINS = (
|
||||||
@ -164,7 +164,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
|
If `True`, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
|
||||||
|
|
||||||
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
|
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
|
||||||
|
|
||||||
@ -212,7 +212,7 @@ The view name or URL to which a user is redirected after logging out.
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
|
If `True`, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -220,7 +220,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
|
If `True`, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -236,7 +236,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
|
If `True`, all non-HTTPS requests will be automatically redirected to use HTTPS.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect.
|
Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect.
|
||||||
@ -255,7 +255,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
If `True`, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ Default: `('127.0.0.1', '::1')`
|
|||||||
|
|
||||||
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
|
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
|
||||||
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
|
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
|
||||||
addresses (and [`DEBUG`](./development.md#debug) is true).
|
addresses (and [`DEBUG`](./development.md#debug) is `True`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
|
Set this configuration parameter to `True` for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
If Internet access is available via a proxy, set [`HTTP_PROXIES`](#http_proxies) instead.
|
If Internet access is available via a proxy, set [`HTTP_PROXIES`](#http_proxies) instead.
|
||||||
@ -114,7 +114,7 @@ Set this configuration parameter to True for NetBox deployments which do not hav
|
|||||||
|
|
||||||
Default: `{}`
|
Default: `{}`
|
||||||
|
|
||||||
A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
|
A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def uppercase(x):
|
def uppercase(x):
|
||||||
@ -158,6 +158,7 @@ LOGGING = {
|
|||||||
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
||||||
* `netbox.auth.*` - Authentication events
|
* `netbox.auth.*` - Authentication events
|
||||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||||
|
* `netbox.event_rules` - Event rules
|
||||||
* `netbox.reports.*` - Report execution (`module.name`)
|
* `netbox.reports.*` - Report execution (`module.name`)
|
||||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||||
|
@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
|
|||||||
|
|
||||||
## Populating Demo Data
|
## Populating Demo Data
|
||||||
|
|
||||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
|
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
|
||||||
|
|
||||||
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
||||||
|
|
||||||
|
@ -54,6 +54,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
|
|||||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
||||||
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
* Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
|
||||||
|
* Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
|
||||||
|
|
||||||
### Manually Perform a New Install
|
### Manually Perform a New Install
|
||||||
|
|
||||||
@ -165,7 +166,8 @@ Then, compile these portable (`.po`) files for use in the application:
|
|||||||
|
|
||||||
### Update Version and Changelog
|
### Update Version and Changelog
|
||||||
|
|
||||||
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||||
|
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
|
||||||
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
||||||
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||||
|
|
||||||
@ -191,15 +193,3 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
|
|||||||
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
|
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
|
||||||
|
|
||||||
Once created, the release will become available for users to install.
|
Once created, the release will become available for users to install.
|
||||||
|
|
||||||
### Update the Public Documentation
|
|
||||||
|
|
||||||
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
|
|
||||||
|
|
||||||
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
|
|
||||||
|
|
||||||
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
|
|
||||||
|
|
||||||
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _Cache_ in the left-nav, click the _Clear Cache_ button, and confirm the clear operation.
|
|
||||||
|
|
||||||
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.
|
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
NetBox includes the ability to execute certain functions as background tasks. These include:
|
NetBox includes the ability to execute certain functions as background tasks. These include:
|
||||||
|
|
||||||
* [Report](../customization/reports.md) execution
|
|
||||||
* [Custom script](../customization/custom-scripts.md) execution
|
* [Custom script](../customization/custom-scripts.md) execution
|
||||||
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
||||||
|
* Housekeeping tasks
|
||||||
|
|
||||||
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
|
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ NetBox is the leading solution for modeling and documenting modern networks. By
|
|||||||
|
|
||||||
## :material-server-network: Built for Networks
|
## :material-server-network: Built for Networks
|
||||||
|
|
||||||
Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:
|
Unlike general-purpose configuration management databases (CMDBs), NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:
|
||||||
|
|
||||||
* Hierarchical regions, sites, and locations
|
* Hierarchical regions, sites, and locations
|
||||||
* Racks, devices, and device components
|
* Racks, devices, and device components
|
||||||
|
@ -108,7 +108,7 @@ Open `configuration.py` with your preferred editor to begin configuring NetBox.
|
|||||||
|
|
||||||
### ALLOWED_HOSTS
|
### ALLOWED_HOSTS
|
||||||
|
|
||||||
This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting).)
|
This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/stable/topics/security/#host-headers-virtual-hosting).)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||||
|
@ -28,7 +28,7 @@ NetBox ships with a default configuration file for uWSGI. To use it, copy `/opt/
|
|||||||
sudo cp /opt/netbox/contrib/uwsgi.ini /opt/netbox/uwsgi.ini
|
sudo cp /opt/netbox/contrib/uwsgi.ini /opt/netbox/uwsgi.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the uWSGI documentation](https://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html) for the available configuration parameters and take a minute to review the [Things to know](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html) page. Django also provides [additional documentation](https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/uwsgi/) on configuring uWSGI with a Django app.
|
While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the uWSGI documentation](https://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html) for the available configuration parameters and take a minute to review the [Things to know](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html) page. Django also provides [additional documentation](https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/uwsgi/) on configuring uWSGI with a Django app.
|
||||||
|
|
||||||
## systemd Setup
|
## systemd Setup
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
|||||||
|
|
||||||
### Option B: Check Out a Git Release
|
### Option B: Check Out a Git Release
|
||||||
|
|
||||||
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
|
This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
|
||||||
|
|
||||||
```
|
```
|
||||||
git ls-remote --tags https://github.com/netbox-community/netbox.git \
|
git ls-remote --tags https://github.com/netbox-community/netbox.git \
|
||||||
@ -134,6 +134,8 @@ git ls-remote --tags https://github.com/netbox-community/netbox.git \
|
|||||||
Check out the desired release by specifying its tag. For example:
|
Check out the desired release by specifying its tag. For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
cd /opt/netbox && \
|
||||||
|
sudo git fetch --tags && \
|
||||||
sudo git checkout v4.2.7
|
sudo git checkout v4.2.7
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
|
|||||||
```python title="jobs.py"
|
```python title="jobs.py"
|
||||||
from netbox.jobs import JobRunner
|
from netbox.jobs import JobRunner
|
||||||
|
|
||||||
|
|
||||||
class MyTestJob(JobRunner):
|
class MyTestJob(JobRunner):
|
||||||
class Meta:
|
class Meta:
|
||||||
name = "My Test Job"
|
name = "My Test Job"
|
||||||
@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
|
|||||||
# your logic goes here
|
# your logic goes here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
|
||||||
|
|
||||||
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
|
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
@ -22,7 +22,7 @@ from netbox.plugins import PluginConfig
|
|||||||
|
|
||||||
### ContentType renamed to ObjectType
|
### ContentType renamed to ObjectType
|
||||||
|
|
||||||
NetBox's proxy model for Django's [ContentType model](https://docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/#the-contenttype-model) has been renamed to ObjectType for clarity. In general, plugins should use the ObjectType proxy when referencing content types, as it includes several custom manager methods. The one exception to this is when defining [generic foreign keys](https://docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/#generic-relations): The ForeignKey field used for a GFK should point to Django's native ContentType.
|
NetBox's proxy model for Django's [ContentType model](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#the-contenttype-model) has been renamed to ObjectType for clarity. In general, plugins should use the ObjectType proxy when referencing content types, as it includes several custom manager methods. The one exception to this is when defining [generic foreign keys](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#generic-relations): The ForeignKey field used for a GFK should point to Django's native ContentType.
|
||||||
|
|
||||||
Additionally, plugin maintainers are strongly encouraged to adopt the "object type" terminology for field and filter names wherever feasible to be consistent with NetBox core (however this is not required for compatibility).
|
Additionally, plugin maintainers are strongly encouraged to adopt the "object type" terminology for field and filter names wherever feasible to be consistent with NetBox core (however this is not required for compatibility).
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Views
|
# Views
|
||||||
|
|
||||||
## Writing Views
|
## Writing Basic Views
|
||||||
|
|
||||||
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
|
If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`.
|
||||||
|
|
||||||
@ -47,9 +47,13 @@ A URL pattern has three components:
|
|||||||
|
|
||||||
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
|
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
|
||||||
|
|
||||||
|
## NetBox Model Views
|
||||||
|
|
||||||
|
NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.
|
||||||
|
|
||||||
### View Classes
|
### View Classes
|
||||||
|
|
||||||
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
Generic view classes (documented below) facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
|
||||||
|
|
||||||
| View Class | Description |
|
| View Class | Description |
|
||||||
|----------------------|--------------------------------------------------------|
|
|----------------------|--------------------------------------------------------|
|
||||||
@ -65,18 +69,51 @@ NetBox provides several generic view classes (documented below) to facilitate co
|
|||||||
!!! warning
|
!!! warning
|
||||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
|
||||||
|
|
||||||
#### Example Usage
|
### URL registration
|
||||||
|
|
||||||
|
The NetBox URL registration process has two parts:
|
||||||
|
|
||||||
|
1. View classes can be decorated with `@register_model_view()`. This registers a new URL for the model.
|
||||||
|
2. All of a model's URLs can be included in `urls.py` using the `get_model_urls()` function. This call is usually required twice: once to import general views for the model and again to import model detail views tied to the object's primary key.
|
||||||
|
|
||||||
|
::: utilities.views.register_model_view
|
||||||
|
|
||||||
|
!!! note "Changed in NetBox v4.2"
|
||||||
|
In NetBox v4.2, the `register_model_view()` function was extended to support the registration of list views by passing `detail=False`.
|
||||||
|
|
||||||
|
::: utilities.urls.get_model_urls
|
||||||
|
|
||||||
|
!!! note "Changed in NetBox v4.2"
|
||||||
|
In NetBox v4.2, the `get_model_urls()` function was extended to support retrieving registered general model views (e.g. for listing objects) by passing `detail=False`.
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# views.py
|
# views.py
|
||||||
from netbox.views.generic import ObjectEditView
|
from netbox.views.generic import ObjectEditView
|
||||||
|
from utilities.views import register_model_view
|
||||||
from .models import Thing
|
from .models import Thing
|
||||||
|
|
||||||
|
@register_model_view(Thing, name='add', detail=False)
|
||||||
|
@register_model_view(Thing, name='edit')
|
||||||
class ThingEditView(ObjectEditView):
|
class ThingEditView(ObjectEditView):
|
||||||
queryset = Thing.objects.all()
|
queryset = Thing.objects.all()
|
||||||
template_name = 'myplugin/thing.html'
|
template_name = 'myplugin/thing.html'
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# urls.py
|
||||||
|
from django.urls import include, path
|
||||||
|
from utilities.urls import get_model_urls
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('thing/', include(get_model_urls('myplugin', 'thing', detail=False))),
|
||||||
|
path('thing/<int:pk>/', include(get_model_urls('myplugin', 'thing'))),
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
## Object Views
|
## Object Views
|
||||||
|
|
||||||
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
|
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
|
||||||
@ -143,6 +180,9 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
|||||||
|
|
||||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
These feature views are automatically registered for all models that implement the respective feature. There is usually no need to override them. However, if that's the case, the URL must be registered manually in `urls.py` instead of using the `register_model_view()` function or decorator.
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectChangeLogView
|
::: netbox.views.generic.ObjectChangeLogView
|
||||||
options:
|
options:
|
||||||
members:
|
members:
|
||||||
@ -157,7 +197,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
|
|||||||
|
|
||||||
### Additional Tabs
|
### Additional Tabs
|
||||||
|
|
||||||
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
|
Plugins can "attach" a custom view to a NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
@ -185,11 +225,6 @@ class MyView(generic.ObjectView):
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note "Changed in NetBox v4.2"
|
|
||||||
The `register_model_view()` function was extended in NetBox v4.2 to support registration of list views by passing `detail=False`.
|
|
||||||
|
|
||||||
::: utilities.views.register_model_view
|
|
||||||
|
|
||||||
::: utilities.views.ViewTab
|
::: utilities.views.ViewTab
|
||||||
|
|
||||||
### Extra Template Content
|
### Extra Template Content
|
||||||
|
@ -86,3 +86,69 @@ netbox=> DELETE FROM django_migrations WHERE app='pluginname';
|
|||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
||||||
|
|
||||||
|
## Clean Up Content Types and Permissions
|
||||||
|
|
||||||
|
After removing a plugin and its database tables, you may find that object type references (`ContentTypes`) created by the plugin still appear in the permissions management section (e.g., when editing permissions in the NetBox UI).
|
||||||
|
This happens because the `django_content_type` table retains entries for the models that the plugin registered with Django.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Please use caution when removing `ContentTypes`. It is strongly recommended to **back up your database** before making these changes.
|
||||||
|
|
||||||
|
**Identify Stale Content Types:**
|
||||||
|
|
||||||
|
Open the Django shell to inspect lingering `ContentType` entries related to the removed plugin.
|
||||||
|
Typically, the Content Type's `app_label` matches the plugin’s name.
|
||||||
|
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
$ cd /opt/netbox/
|
||||||
|
$ source /opt/netbox/venv/bin/activate
|
||||||
|
(venv) $ python3 netbox/manage.py nbshell
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in the shell:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
# Replace 'pluginname' with your plugin's actual name
|
||||||
|
stale_types = ContentType.objects.filter(app_label="pluginname")
|
||||||
|
for ct in stale_types:
|
||||||
|
print(ct)
|
||||||
|
### ^^^ These will be removed, make sure its ok
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Review the output carefully and confirm that each listed Content Type is related to the plugin you removed.
|
||||||
|
|
||||||
|
**Remove Stale Content Types and Related Permissions:**
|
||||||
|
|
||||||
|
Next, check for any permissions associated with these Content Types:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
for ct in stale_types:
|
||||||
|
perms = Permission.objects.filter(content_type=ct)
|
||||||
|
print(list(perms))
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are related Permissions, you can remove them safely:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
for ct in stale_types:
|
||||||
|
Permission.objects.filter(content_type=ct).delete()
|
||||||
|
```
|
||||||
|
|
||||||
|
After removing any related permissions, delete the Content Type entries:
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
stale_types.delete()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restart NetBox:**
|
||||||
|
|
||||||
|
After making these changes, restart the NetBox service to ensure all changes are reflected.
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
sudo systemctl restart netbox
|
||||||
|
```
|
||||||
|
@ -1,5 +1,81 @@
|
|||||||
# NetBox v4.3
|
# NetBox v4.3
|
||||||
|
|
||||||
|
## v4.3.4 (2025-07-15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
|
||||||
|
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
|
||||||
|
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
|
||||||
|
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
|
||||||
|
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
|
||||||
|
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
|
||||||
|
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
|
||||||
|
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
|
||||||
|
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
|
||||||
|
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
|
||||||
|
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.3.3 (2025-06-26)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#17183](https://github.com/netbox-community/netbox/issues/17183) - Enable associating tags with object types during bulk import
|
||||||
|
* [#17719](https://github.com/netbox-community/netbox/issues/17719) - Introduce a user preference for table row striping
|
||||||
|
* [#19492](https://github.com/netbox-community/netbox/issues/19492) - Add a UI button to download the output of an executed custom script
|
||||||
|
* [#19499](https://github.com/netbox-community/netbox/issues/19499) - Support qualifying interfaces by parent device when bulk importing wireless links
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#19529](https://github.com/netbox-community/netbox/issues/19529) - Fix support for running custom scripts via the `runscript` management command
|
||||||
|
* [#19555](https://github.com/netbox-community/netbox/issues/19555) - Fix support for `schedule_at` when invoking a custom script via the REST API
|
||||||
|
* [#19617](https://github.com/netbox-community/netbox/issues/19617) - Ensure consistent styling of "connect" buttons in UI
|
||||||
|
* [#19640](https://github.com/netbox-community/netbox/issues/19640) - Restore ability to filter FHRP group assignments by device/VM in GraphQL API
|
||||||
|
* [#19644](https://github.com/netbox-community/netbox/issues/19644) - Atomic transactions should always employ database routing
|
||||||
|
* [#19659](https://github.com/netbox-community/netbox/issues/19659) - Populate initial device/VM selection for "add a service" button
|
||||||
|
* [#19665](https://github.com/netbox-community/netbox/issues/19665) - Correct field reference in wireless link model validation
|
||||||
|
* [#19667](https://github.com/netbox-community/netbox/issues/19667) - Fix `TypeError` exception when creating a new module profile type with no schema
|
||||||
|
* [#19673](https://github.com/netbox-community/netbox/issues/19673) - Ignore custom field references when compiling table prefetches
|
||||||
|
* [#19677](https://github.com/netbox-community/netbox/issues/19677) - Fix exception when passing null value to `present_in_vrf` filter
|
||||||
|
* [#19680](https://github.com/netbox-community/netbox/issues/19680) - Correct chronological ordering of change records resulting from device deletions
|
||||||
|
* [#19687](https://github.com/netbox-community/netbox/issues/19687) - Cellular interface types should be considered non-connectable
|
||||||
|
* [#19702](https://github.com/netbox-community/netbox/issues/19702) - Fix `DoesNotExist` exception when deleting a notification group with an associated event rule
|
||||||
|
* [#19745](https://github.com/netbox-community/netbox/issues/19745) - Fix bulk import of services with IP addresses assigned to FHRP groups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.3.2 (2025-06-05)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#19200](https://github.com/netbox-community/netbox/issues/19200) - Display assigned virtual chassis (if any) on device view
|
||||||
|
* [#19461](https://github.com/netbox-community/netbox/issues/19461) - Add color backgrounds for virtual circuit types
|
||||||
|
* [#19605](https://github.com/netbox-community/netbox/issues/19605) - Enable filtering IP addresses by family in GraphQL API
|
||||||
|
* [#19627](https://github.com/netbox-community/netbox/issues/19627) - Introduce object change migrators
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#19415](https://github.com/netbox-community/netbox/issues/19415) - Increase maximum supported distance for circuits and wireless links
|
||||||
|
* [#19475](https://github.com/netbox-community/netbox/issues/19475) - VLANs belonging to the same location as a VM's cluster should be eligible for assignment to interfaces on that VM
|
||||||
|
* [#19486](https://github.com/netbox-community/netbox/issues/19486) - Fix connection card rendering for console server ports
|
||||||
|
* [#19487](https://github.com/netbox-community/netbox/issues/19487) - Fix `FieldError` exception when ordering circuit or tunnel terminations by the terminating object
|
||||||
|
* [#19490](https://github.com/netbox-community/netbox/issues/19490) - Fix inclusion support for config templates populated via a data source
|
||||||
|
* [#19496](https://github.com/netbox-community/netbox/issues/19496) - Fix `AttributeError` exception when rendering a config template with no output
|
||||||
|
* [#19510](https://github.com/netbox-community/netbox/issues/19510) - Restore GraphQL API filtering for assigned IP addresses
|
||||||
|
* [#19520](https://github.com/netbox-community/netbox/issues/19520) - Restore ability to alter prefix scope via the REST API
|
||||||
|
* [#19587](https://github.com/netbox-community/netbox/issues/19587) - The `occupied` filter should include interfaces terminating a wireless link
|
||||||
|
* [#19599](https://github.com/netbox-community/netbox/issues/19599) - Fix `AttributeError` exception when sorting change history under user view
|
||||||
|
* [#19610](https://github.com/netbox-community/netbox/issues/19610) - Fix `FieldError` exception when sorting tunnel terminations by tenant
|
||||||
|
* [#19623](https://github.com/netbox-community/netbox/issues/19623) - Display description under provider account view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.3.1 (2025-05-13)
|
## v4.3.1 (2025-05-13)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -191,12 +191,9 @@ class ProfileView(LoginRequiredMixin, View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|
||||||
# Compile changelog table
|
# Compile changelog table
|
||||||
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=request.user)[:20]
|
||||||
user=request.user
|
|
||||||
).prefetch_related(
|
|
||||||
'changed_object_type'
|
|
||||||
)[:20]
|
|
||||||
changelog_table = ObjectChangeTable(changelog)
|
changelog_table = ObjectChangeTable(changelog)
|
||||||
|
changelog_table.orderable = False
|
||||||
changelog_table.configure(request)
|
changelog_table.configure(request)
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
|
@ -16,6 +16,7 @@ from utilities.forms import get_field_value
|
|||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
|
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
|
||||||
)
|
)
|
||||||
|
from utilities.forms.mixins import DistanceValidationMixin
|
||||||
from utilities.forms.rendering import FieldSet, InlineFields
|
from utilities.forms.rendering import FieldSet, InlineFields
|
||||||
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
|
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
|
||||||
from utilities.templatetags.builtins.filters import bettertitle
|
from utilities.templatetags.builtins.filters import bettertitle
|
||||||
@ -105,7 +106,7 @@ class CircuitTypeForm(NetBoxModelForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CircuitForm(TenancyForm, NetBoxModelForm):
|
class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
|
||||||
provider = DynamicModelChoiceField(
|
provider = DynamicModelChoiceField(
|
||||||
label=_('Provider'),
|
label=_('Provider'),
|
||||||
queryset=Provider.objects.all(),
|
queryset=Provider.objects.all(),
|
||||||
|
@ -41,7 +41,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CircuitTermination, lookups=True)
|
@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
|
||||||
class CircuitTerminationFilter(
|
class CircuitTerminationFilter(
|
||||||
BaseObjectTypeFilterMixin,
|
BaseObjectTypeFilterMixin,
|
||||||
CustomFieldsFilterMixin,
|
CustomFieldsFilterMixin,
|
||||||
@ -87,7 +87,7 @@ class CircuitTerminationFilter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Circuit, lookups=True)
|
@strawberry_django.filter_type(models.Circuit, lookups=True)
|
||||||
class CircuitFilter(
|
class CircuitFilter(
|
||||||
ContactFilterMixin,
|
ContactFilterMixin,
|
||||||
ImageAttachmentFilterMixin,
|
ImageAttachmentFilterMixin,
|
||||||
@ -121,17 +121,17 @@ class CircuitFilter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CircuitType, lookups=True)
|
@strawberry_django.filter_type(models.CircuitType, lookups=True)
|
||||||
class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
|
class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CircuitGroup, lookups=True)
|
@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
|
||||||
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
|
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
|
@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
|
||||||
class CircuitGroupAssignmentFilter(
|
class CircuitGroupAssignmentFilter(
|
||||||
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
|
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
|
||||||
):
|
):
|
||||||
@ -148,7 +148,7 @@ class CircuitGroupAssignmentFilter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Provider, lookups=True)
|
@strawberry_django.filter_type(models.Provider, lookups=True)
|
||||||
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -158,7 +158,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ProviderAccount, lookups=True)
|
@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
|
||||||
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
||||||
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -168,7 +168,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
|
|||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ProviderNetwork, lookups=True)
|
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
|
||||||
class ProviderNetworkFilter(PrimaryModelFilterMixin):
|
class ProviderNetworkFilter(PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||||
@ -178,12 +178,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
|
|||||||
service_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
service_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
|
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
|
||||||
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
|
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VirtualCircuit, lookups=True)
|
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
|
||||||
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
cid: FilterLookup[str] | None = strawberry_django.filter_field()
|
cid: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
|
||||||
@ -206,7 +206,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
|
@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
|
||||||
class VirtualCircuitTerminationFilter(
|
class VirtualCircuitTerminationFilter(
|
||||||
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
|
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
|
||||||
):
|
):
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -49,3 +50,26 @@ class Migration(migrations.Migration):
|
|||||||
# Copy over existing site assignments
|
# Copy over existing site assignments
|
||||||
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
|
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_circuittermination_termination(objectchange, reverting):
|
||||||
|
site_ct = ContentType.objects.get_by_natural_key('dcim', 'site').pk
|
||||||
|
provider_network_ct = ContentType.objects.get_by_natural_key('circuits', 'providernetwork').pk
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if site_id := data.get('site'):
|
||||||
|
data.update({
|
||||||
|
'termination_type': site_ct,
|
||||||
|
'termination_id': site_id,
|
||||||
|
})
|
||||||
|
elif provider_network_id := data.get('provider_network'):
|
||||||
|
data.update({
|
||||||
|
'termination_type': provider_network_ct,
|
||||||
|
'termination_id': provider_network_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'circuits.circuittermination': oc_circuittermination_termination,
|
||||||
|
}
|
||||||
|
@ -86,3 +86,15 @@ class Migration(migrations.Migration):
|
|||||||
new_name='_provider_network',
|
new_name='_provider_network',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_circuittermination_remove_fields(objectchange, reverting):
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is not None:
|
||||||
|
data.pop('site', None)
|
||||||
|
data.pop('provider_network', None)
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'circuits.circuittermination': oc_circuittermination_remove_fields,
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -82,3 +83,21 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_circuitgroupassignment_member(objectchange, reverting):
|
||||||
|
circuit_ct = ContentType.objects.get_by_natural_key('circuits', 'circuit').pk
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if circuit_id := data.get('circuit'):
|
||||||
|
data.update({
|
||||||
|
'member_type': circuit_ct,
|
||||||
|
'member_id': circuit_id,
|
||||||
|
})
|
||||||
|
data.pop('circuit', None)
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'circuits.circuitgroupassignment': oc_circuitgroupassignment_member,
|
||||||
|
}
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0051_virtualcircuit_group_assignment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='circuit',
|
||||||
|
name='_abs_distance',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=4, max_digits=13, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -120,7 +120,8 @@ class CircuitTerminationTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
termination = tables.Column(
|
termination = tables.Column(
|
||||||
verbose_name=_('Termination Point'),
|
verbose_name=_('Termination Point'),
|
||||||
linkify=True
|
linkify=True,
|
||||||
|
orderable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Termination types
|
# Termination types
|
||||||
@ -132,7 +133,7 @@ class CircuitTerminationTable(NetBoxTable):
|
|||||||
site_group = tables.Column(
|
site_group = tables.Column(
|
||||||
verbose_name=_('Site Group'),
|
verbose_name=_('Site Group'),
|
||||||
linkify=True,
|
linkify=True,
|
||||||
accessor='_sitegroup'
|
accessor='_site_group'
|
||||||
)
|
)
|
||||||
region = tables.Column(
|
region = tables.Column(
|
||||||
verbose_name=_('Region'),
|
verbose_name=_('Region'),
|
||||||
|
@ -54,9 +54,8 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
|||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name=_('Account')
|
verbose_name=_('Account')
|
||||||
)
|
)
|
||||||
type = tables.Column(
|
type = columns.ColoredLabelColumn(
|
||||||
verbose_name=_('Type'),
|
verbose_name=_('Type'),
|
||||||
linkify=True
|
|
||||||
)
|
)
|
||||||
status = columns.ChoiceFieldColumn()
|
status = columns.ChoiceFieldColumn()
|
||||||
termination_count = columns.LinkedCountColumn(
|
termination_count = columns.LinkedCountColumn(
|
||||||
|
23
netbox/circuits/tests/test_tables.py
Normal file
23
netbox/circuits/tests/test_tables.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.test import RequestFactory, tag, TestCase
|
||||||
|
|
||||||
|
from circuits.models import CircuitTermination
|
||||||
|
from circuits.tables import CircuitTerminationTable
|
||||||
|
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
class CircuitTerminationTableTest(TestCase):
|
||||||
|
def test_every_orderable_field_does_not_throw_exception(self):
|
||||||
|
terminations = CircuitTermination.objects.all()
|
||||||
|
disallowed = {'actions', }
|
||||||
|
|
||||||
|
orderable_columns = [
|
||||||
|
column.name for column in CircuitTerminationTable(terminations).columns
|
||||||
|
if column.orderable and column.name not in disallowed
|
||||||
|
]
|
||||||
|
fake_request = RequestFactory().get("/")
|
||||||
|
|
||||||
|
for col in orderable_columns:
|
||||||
|
for dir in ('-', ''):
|
||||||
|
table = CircuitTerminationTable(terminations)
|
||||||
|
table.order_by = f'{dir}{col}'
|
||||||
|
table.as_html(fake_request)
|
@ -1,5 +1,5 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -384,7 +384,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
|||||||
|
|
||||||
if termination_a and termination_z:
|
if termination_a and termination_z:
|
||||||
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
|
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(CircuitTermination)):
|
||||||
termination_a.term_side = '_'
|
termination_a.term_side = '_'
|
||||||
termination_a.save()
|
termination_a.save()
|
||||||
termination_z.term_side = 'A'
|
termination_z.term_side = 'A'
|
||||||
|
@ -1,9 +1,19 @@
|
|||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
class SyncError(Exception):
|
'IncompatiblePluginError',
|
||||||
pass
|
'JobFailed',
|
||||||
|
'SyncError',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IncompatiblePluginError(ImproperlyConfigured):
|
class IncompatiblePluginError(ImproperlyConfigured):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JobFailed(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SyncError(Exception):
|
||||||
|
pass
|
||||||
|
@ -23,7 +23,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DataFile, lookups=True)
|
@strawberry_django.filter_type(models.DataFile, lookups=True)
|
||||||
class DataFileFilter(BaseFilterMixin):
|
class DataFileFilter(BaseFilterMixin):
|
||||||
id: ID | None = strawberry_django.filter_field()
|
id: ID | None = strawberry_django.filter_field()
|
||||||
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
||||||
@ -39,7 +39,7 @@ class DataFileFilter(BaseFilterMixin):
|
|||||||
hash: FilterLookup[str] | None = strawberry_django.filter_field()
|
hash: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DataSource, lookups=True)
|
@strawberry_django.filter_type(models.DataSource, lookups=True)
|
||||||
class DataSourceFilter(PrimaryModelFilterMixin):
|
class DataSourceFilter(PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
type: FilterLookup[str] | None = strawberry_django.filter_field()
|
type: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -56,7 +56,7 @@ class DataSourceFilter(PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ObjectChange, lookups=True)
|
@strawberry_django.filter_type(models.ObjectChange, lookups=True)
|
||||||
class ObjectChangeFilter(BaseFilterMixin):
|
class ObjectChangeFilter(BaseFilterMixin):
|
||||||
id: ID | None = strawberry_django.filter_field()
|
id: ID | None = strawberry_django.filter_field()
|
||||||
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
|
||||||
@ -82,7 +82,7 @@ class ObjectChangeFilter(BaseFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(DjangoContentType, lookups=True)
|
@strawberry_django.filter_type(DjangoContentType, lookups=True)
|
||||||
class ContentTypeFilter(BaseFilterMixin):
|
class ContentTypeFilter(BaseFilterMixin):
|
||||||
id: ID | None = strawberry_django.filter_field()
|
id: ID | None = strawberry_django.filter_field()
|
||||||
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
|
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
@ -187,15 +187,14 @@ class Job(models.Model):
|
|||||||
"""
|
"""
|
||||||
Mark the job as completed, optionally specifying a particular termination status.
|
Mark the job as completed, optionally specifying a particular termination status.
|
||||||
"""
|
"""
|
||||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
|
||||||
if status not in valid_statuses:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||||
choices=', '.join(valid_statuses)
|
choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark the job as completed
|
# Set the job's status and completion time
|
||||||
self.status = status
|
self.status = status
|
||||||
if error:
|
if error:
|
||||||
self.error = error
|
self.error = error
|
||||||
|
@ -162,6 +162,12 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||||||
getattr(obj, related_field_name).remove(instance)
|
getattr(obj, related_field_name).remove(instance)
|
||||||
elif type(relation) is ManyToOneRel and relation.field.null is True:
|
elif type(relation) is ManyToOneRel and relation.field.null is True:
|
||||||
setattr(obj, related_field_name, None)
|
setattr(obj, related_field_name, None)
|
||||||
|
# make sure the object hasn't been deleted - in case of
|
||||||
|
# deletion chaining of related objects
|
||||||
|
try:
|
||||||
|
obj.refresh_from_db()
|
||||||
|
except DoesNotExist:
|
||||||
|
continue
|
||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
# Enqueue the object for event processing
|
# Enqueue the object for event processing
|
||||||
|
@ -9,6 +9,7 @@ from rq.registry import FailedJobRegistry, StartedJobRegistry
|
|||||||
|
|
||||||
from users.models import Token, User
|
from users.models import Token, User
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, TestCase
|
from utilities.testing import APITestCase, APIViewTestCases, TestCase
|
||||||
|
from utilities.testing.utils import disable_logging
|
||||||
from ..models import *
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
@ -189,7 +190,8 @@ class BackgroundTaskTestCase(TestCase):
|
|||||||
# Enqueue & run a job that will fail
|
# Enqueue & run a job that will fail
|
||||||
job = queue.enqueue(self.dummy_job_failing)
|
job = queue.enqueue(self.dummy_job_failing)
|
||||||
worker = get_worker('default')
|
worker = get_worker('default')
|
||||||
worker.work(burst=True)
|
with disable_logging():
|
||||||
|
worker.work(burst=True)
|
||||||
self.assertTrue(job.is_failed)
|
self.assertTrue(job.is_failed)
|
||||||
|
|
||||||
# Re-enqueue the failed job and check that its status has been reset
|
# Re-enqueue the failed job and check that its status has been reset
|
||||||
@ -231,7 +233,8 @@ class BackgroundTaskTestCase(TestCase):
|
|||||||
self.assertEqual(job.get_status(), JobStatus.STARTED)
|
self.assertEqual(job.get_status(), JobStatus.STARTED)
|
||||||
response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
|
response = self.client.post(reverse('core-api:rqtask-stop', args=[job.id]), **self.header)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
|
with disable_logging():
|
||||||
|
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
|
||||||
started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
|
started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
|
||||||
self.assertEqual(len(started_job_registry), 0)
|
self.assertEqual(len(started_job_registry), 0)
|
||||||
|
|
||||||
|
@ -6,12 +6,13 @@ from rest_framework import status
|
|||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.models import ObjectChange, ObjectType
|
from core.models import ObjectChange, ObjectType
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import CustomField, CustomFieldChoiceSet, Tag
|
from extras.models import CustomField, CustomFieldChoiceSet, 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
|
||||||
|
from dcim.models import Manufacturer
|
||||||
|
|
||||||
|
|
||||||
class ChangeLogViewTest(ModelViewTestCase):
|
class ChangeLogViewTest(ModelViewTestCase):
|
||||||
@ -270,6 +271,81 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
# Check that no ObjectChange records have been created
|
# Check that no ObjectChange records have been created
|
||||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_ordering_genericrelation(self):
|
||||||
|
# Create required objects first
|
||||||
|
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
|
||||||
|
device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model='Model 1',
|
||||||
|
slug='model-1'
|
||||||
|
)
|
||||||
|
device_role = DeviceRole.objects.create(
|
||||||
|
name='Role 1',
|
||||||
|
slug='role-1'
|
||||||
|
)
|
||||||
|
site = Site.objects.create(
|
||||||
|
name='Site 1',
|
||||||
|
slug='site-1'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create two devices
|
||||||
|
device1 = Device.objects.create(
|
||||||
|
name='Device 1',
|
||||||
|
device_type=device_type,
|
||||||
|
role=device_role,
|
||||||
|
site=site
|
||||||
|
)
|
||||||
|
device2 = Device.objects.create(
|
||||||
|
name='Device 2',
|
||||||
|
device_type=device_type,
|
||||||
|
role=device_role,
|
||||||
|
site=site
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create interfaces on both devices
|
||||||
|
interface1 = Interface.objects.create(
|
||||||
|
device=device1,
|
||||||
|
name='eth0',
|
||||||
|
type='1000base-t'
|
||||||
|
)
|
||||||
|
interface2 = Interface.objects.create(
|
||||||
|
device=device2,
|
||||||
|
name='eth0',
|
||||||
|
type='1000base-t'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a cable between the interfaces
|
||||||
|
_ = Cable.objects.create(
|
||||||
|
a_terminations=[interface1],
|
||||||
|
b_terminations=[interface2],
|
||||||
|
status='connected'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete device1
|
||||||
|
request = {
|
||||||
|
'path': reverse('dcim:device_delete', kwargs={'pk': device1.pk}),
|
||||||
|
'data': post_data({'confirm': True}),
|
||||||
|
}
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.delete_device',
|
||||||
|
'dcim.delete_interface',
|
||||||
|
'dcim.delete_cable',
|
||||||
|
'dcim.delete_cabletermination'
|
||||||
|
)
|
||||||
|
response = self.client.post(**request)
|
||||||
|
self.assertHttpStatus(response, 302)
|
||||||
|
|
||||||
|
# Get the ObjectChange records for delete actions ordered by time
|
||||||
|
changes = ObjectChange.objects.filter(
|
||||||
|
action=ObjectChangeActionChoices.ACTION_DELETE
|
||||||
|
).order_by('time')[:3]
|
||||||
|
|
||||||
|
# Verify the order of deletion
|
||||||
|
self.assertEqual(len(changes), 3)
|
||||||
|
self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(CableTermination))
|
||||||
|
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
|
||||||
|
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
|
||||||
|
|
||||||
|
|
||||||
class ChangeLogAPITest(APITestCase):
|
class ChangeLogAPITest(APITestCase):
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ from core.choices import ObjectChangeActionChoices
|
|||||||
from core.models import *
|
from core.models import *
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.testing import TestCase, ViewTestCases, create_tags
|
from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
|
||||||
|
|
||||||
|
|
||||||
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
@ -271,7 +271,8 @@ class BackgroundTaskTestCase(TestCase):
|
|||||||
# Enqueue & run a job that will fail
|
# Enqueue & run a job that will fail
|
||||||
job = queue.enqueue(self.dummy_job_failing)
|
job = queue.enqueue(self.dummy_job_failing)
|
||||||
worker = get_worker('default')
|
worker = get_worker('default')
|
||||||
worker.work(burst=True)
|
with disable_logging():
|
||||||
|
worker.work(burst=True)
|
||||||
self.assertTrue(job.is_failed)
|
self.assertTrue(job.is_failed)
|
||||||
|
|
||||||
# Re-enqueue the failed job and check that its status has been reset
|
# Re-enqueue the failed job and check that its status has been reset
|
||||||
@ -317,7 +318,8 @@ class BackgroundTaskTestCase(TestCase):
|
|||||||
self.assertEqual(len(started_job_registry), 1)
|
self.assertEqual(len(started_job_registry), 1)
|
||||||
response = self.client.get(reverse('core:background_task_stop', args=[job.id]))
|
response = self.client.get(reverse('core:background_task_stop', args=[job.id]))
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
|
with disable_logging():
|
||||||
|
worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
|
||||||
self.assertEqual(len(started_job_registry), 0)
|
self.assertEqual(len(started_job_registry), 0)
|
||||||
|
|
||||||
canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)
|
canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)
|
||||||
|
@ -53,6 +53,11 @@ WIRELESS_IFACE_TYPES = [
|
|||||||
InterfaceTypeChoices.TYPE_802151,
|
InterfaceTypeChoices.TYPE_802151,
|
||||||
InterfaceTypeChoices.TYPE_802154,
|
InterfaceTypeChoices.TYPE_802154,
|
||||||
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
||||||
|
InterfaceTypeChoices.TYPE_GSM,
|
||||||
|
InterfaceTypeChoices.TYPE_CDMA,
|
||||||
|
InterfaceTypeChoices.TYPE_LTE,
|
||||||
|
InterfaceTypeChoices.TYPE_4G,
|
||||||
|
InterfaceTypeChoices.TYPE_5G,
|
||||||
]
|
]
|
||||||
|
|
||||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||||
|
@ -2012,6 +2012,21 @@ class InterfaceFilterSet(
|
|||||||
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
|
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
|
||||||
}.get(value, queryset.none())
|
}.get(value, queryset.none())
|
||||||
|
|
||||||
|
# Override the method on CabledObjectFilterSet to also check for wireless links
|
||||||
|
def filter_occupied(self, queryset, name, value):
|
||||||
|
if value:
|
||||||
|
return queryset.filter(
|
||||||
|
Q(cable__isnull=False) |
|
||||||
|
Q(wireless_link__isnull=False) |
|
||||||
|
Q(mark_connected=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return queryset.filter(
|
||||||
|
cable__isnull=True,
|
||||||
|
wireless_link__isnull=True,
|
||||||
|
mark_connected=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FrontPortFilterSet(
|
class FrontPortFilterSet(
|
||||||
ModularDeviceComponentFilterSet,
|
ModularDeviceComponentFilterSet,
|
||||||
|
@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
|
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||||
'tags',
|
'comments', 'tags'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Cable, lookups=True)
|
@strawberry_django.filter_type(models.Cable, lookups=True)
|
||||||
class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
|
class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
|
||||||
type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
@ -107,7 +107,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CableTermination, lookups=True)
|
@strawberry_django.filter_type(models.CableTermination, lookups=True)
|
||||||
class CableTerminationFilter(ChangeLogFilterMixin):
|
class CableTerminationFilter(ChangeLogFilterMixin):
|
||||||
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
cable_id: ID | None = strawberry_django.filter_field()
|
cable_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -120,7 +120,7 @@ class CableTerminationFilter(ChangeLogFilterMixin):
|
|||||||
termination_id: ID | None = strawberry_django.filter_field()
|
termination_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConsolePort, lookups=True)
|
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
|
||||||
class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -130,14 +130,14 @@ class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConsolePortTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
|
||||||
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
|
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConsoleServerPort, lookups=True)
|
@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
|
||||||
class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -147,14 +147,14 @@ class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectMode
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
|
||||||
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Device, lookups=True)
|
@strawberry_django.filter_type(models.Device, lookups=True)
|
||||||
class DeviceFilter(
|
class DeviceFilter(
|
||||||
ContactFilterMixin,
|
ContactFilterMixin,
|
||||||
TenancyFilterMixin,
|
TenancyFilterMixin,
|
||||||
@ -271,7 +271,7 @@ class DeviceFilter(
|
|||||||
inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DeviceBay, lookups=True)
|
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
|
||||||
class DeviceBayFilter(ComponentModelFilterMixin):
|
class DeviceBayFilter(ComponentModelFilterMixin):
|
||||||
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -279,12 +279,12 @@ class DeviceBayFilter(ComponentModelFilterMixin):
|
|||||||
installed_device_id: ID | None = strawberry_django.filter_field()
|
installed_device_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DeviceBayTemplate, lookups=True)
|
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
|
||||||
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
|
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.InventoryItemTemplate, lookups=True)
|
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
|
||||||
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
|
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
|
||||||
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -304,13 +304,13 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
|
|||||||
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DeviceRole, lookups=True)
|
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
|
||||||
class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
|
class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
|
vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.DeviceType, lookups=True)
|
@strawberry_django.filter_type(models.DeviceType, lookups=True)
|
||||||
class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
||||||
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -382,7 +382,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.FrontPort, lookups=True)
|
@strawberry_django.filter_type(models.FrontPort, lookups=True)
|
||||||
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
@ -395,7 +395,7 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.FrontPortTemplate, lookups=True)
|
@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
|
||||||
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
@ -408,7 +408,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.MACAddress, lookups=True)
|
@strawberry_django.filter_type(models.MACAddress, lookups=True)
|
||||||
class MACAddressFilter(PrimaryModelFilterMixin):
|
class MACAddressFilter(PrimaryModelFilterMixin):
|
||||||
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||||
@ -417,7 +417,7 @@ class MACAddressFilter(PrimaryModelFilterMixin):
|
|||||||
assigned_object_id: ID | None = strawberry_django.filter_field()
|
assigned_object_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Interface, lookups=True)
|
@strawberry_django.filter_type(models.Interface, lookups=True)
|
||||||
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
|
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
|
||||||
vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -486,7 +486,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.InterfaceTemplate, lookups=True)
|
@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
|
||||||
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
|
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -508,7 +508,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.InventoryItem, lookups=True)
|
@strawberry_django.filter_type(models.InventoryItem, lookups=True)
|
||||||
class InventoryItemFilter(ComponentModelFilterMixin):
|
class InventoryItemFilter(ComponentModelFilterMixin):
|
||||||
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -535,12 +535,12 @@ class InventoryItemFilter(ComponentModelFilterMixin):
|
|||||||
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
|
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.InventoryItemRole, lookups=True)
|
@strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
|
||||||
class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
|
class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Location, lookups=True)
|
@strawberry_django.filter_type(models.Location, lookups=True)
|
||||||
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
|
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
|
||||||
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
site_id: ID | None = strawberry_django.filter_field()
|
site_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -556,12 +556,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Manufacturer, lookups=True)
|
@strawberry_django.filter_type(models.Manufacturer, lookups=True)
|
||||||
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
|
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Module, lookups=True)
|
@strawberry_django.filter_type(models.Module, lookups=True)
|
||||||
class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
|
class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
|
||||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
device_id: ID | None = strawberry_django.filter_field()
|
device_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -610,7 +610,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ModuleBay, lookups=True)
|
@strawberry_django.filter_type(models.ModuleBay, lookups=True)
|
||||||
class ModuleBayFilter(ModularComponentModelFilterMixin):
|
class ModuleBayFilter(ModularComponentModelFilterMixin):
|
||||||
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -619,17 +619,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin):
|
|||||||
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ModuleBayTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
|
||||||
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
|
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ModuleTypeProfile, lookups=True)
|
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
|
||||||
class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
|
class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ModuleType, lookups=True)
|
@strawberry_django.filter_type(models.ModuleType, lookups=True)
|
||||||
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
||||||
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -676,7 +676,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
) = strawberry_django.filter_field()
|
) = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Platform, lookups=True)
|
@strawberry_django.filter_type(models.Platform, lookups=True)
|
||||||
class PlatformFilter(OrganizationalModelFilterMixin):
|
class PlatformFilter(OrganizationalModelFilterMixin):
|
||||||
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -688,7 +688,7 @@ class PlatformFilter(OrganizationalModelFilterMixin):
|
|||||||
config_template_id: ID | None = strawberry_django.filter_field()
|
config_template_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerFeed, lookups=True)
|
@strawberry_django.filter_type(models.PowerFeed, lookups=True)
|
||||||
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -723,7 +723,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerOutlet, lookups=True)
|
@strawberry_django.filter_type(models.PowerOutlet, lookups=True)
|
||||||
class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -738,7 +738,7 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
|
|||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerOutletTemplate, lookups=True)
|
@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
|
||||||
class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
|
class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
|
||||||
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -752,7 +752,7 @@ class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerPanel, lookups=True)
|
@strawberry_django.filter_type(models.PowerPanel, lookups=True)
|
||||||
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
|
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
|
||||||
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
site_id: ID | None = strawberry_django.filter_field()
|
site_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -765,7 +765,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
|
|||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerPort, lookups=True)
|
@strawberry_django.filter_type(models.PowerPort, lookups=True)
|
||||||
class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -778,7 +778,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.PowerPortTemplate, lookups=True)
|
@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
|
||||||
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -791,7 +791,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RackType, lookups=True)
|
@strawberry_django.filter_type(models.RackType, lookups=True)
|
||||||
class RackTypeFilter(RackBaseFilterMixin):
|
class RackTypeFilter(RackBaseFilterMixin):
|
||||||
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -804,7 +804,7 @@ class RackTypeFilter(RackBaseFilterMixin):
|
|||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Rack, lookups=True)
|
@strawberry_django.filter_type(models.Rack, lookups=True)
|
||||||
class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
|
class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
|
||||||
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -836,7 +836,7 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RackReservation, lookups=True)
|
@strawberry_django.filter_type(models.RackReservation, lookups=True)
|
||||||
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
rack_id: ID | None = strawberry_django.filter_field()
|
rack_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -848,12 +848,12 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
|||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RackRole, lookups=True)
|
@strawberry_django.filter_type(models.RackRole, lookups=True)
|
||||||
class RackRoleFilter(OrganizationalModelFilterMixin):
|
class RackRoleFilter(OrganizationalModelFilterMixin):
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RearPort, lookups=True)
|
@strawberry_django.filter_type(models.RearPort, lookups=True)
|
||||||
class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
|
||||||
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
@ -862,7 +862,7 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RearPortTemplate, lookups=True)
|
@strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
|
||||||
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
||||||
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
@ -871,7 +871,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Region, lookups=True)
|
@strawberry_django.filter_type(models.Region, lookups=True)
|
||||||
class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
||||||
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -881,7 +881,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Site, lookups=True)
|
@strawberry_django.filter_type(models.Site, lookups=True)
|
||||||
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -915,7 +915,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.SiteGroup, lookups=True)
|
@strawberry_django.filter_type(models.SiteGroup, lookups=True)
|
||||||
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
||||||
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -925,7 +925,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VirtualChassis, lookups=True)
|
@strawberry_django.filter_type(models.VirtualChassis, lookups=True)
|
||||||
class VirtualChassisFilter(PrimaryModelFilterMixin):
|
class VirtualChassisFilter(PrimaryModelFilterMixin):
|
||||||
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
master_id: ID | None = strawberry_django.filter_field()
|
master_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -937,7 +937,7 @@ class VirtualChassisFilter(PrimaryModelFilterMixin):
|
|||||||
member_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
member_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VirtualDeviceContext, lookups=True)
|
@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
|
||||||
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
device_id: ID | None = strawberry_django.filter_field()
|
device_id: ID | None = strawberry_django.filter_field()
|
||||||
|
@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
|||||||
from tenancy.graphql.types import TenantType
|
from tenancy.graphql.types import TenantType
|
||||||
from users.graphql.types import UserType
|
from users.graphql.types import UserType
|
||||||
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
||||||
|
from vpn.graphql.types import L2VPNTerminationType
|
||||||
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
|||||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
|
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
|
||||||
|
|
||||||
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
||||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||||
|
@ -100,3 +100,16 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_rename_type(objectchange, reverting):
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if 'type' in data:
|
||||||
|
data['form_factor'] = data.pop('type')
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'dcim.rack': oc_rename_type,
|
||||||
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ def populate_mac_addresses(apps, schema_editor):
|
|||||||
assigned_object_type=interface_ct,
|
assigned_object_type=interface_ct,
|
||||||
assigned_object_id=interface.pk
|
assigned_object_id=interface.pk
|
||||||
)
|
)
|
||||||
for interface in Interface.objects.filter(mac_address__isnull=False)
|
for interface in Interface.objects.using(db_alias).filter(mac_address__isnull=False)
|
||||||
]
|
]
|
||||||
MACAddress.objects.using(db_alias).bulk_create(mac_addresses, batch_size=100)
|
MACAddress.objects.using(db_alias).bulk_create(mac_addresses, batch_size=100)
|
||||||
|
|
||||||
@ -51,3 +53,43 @@ class Migration(migrations.Migration):
|
|||||||
name='mac_address',
|
name='mac_address',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# See peer migrator in virtualization.0048_populate_mac_addresses before making changes
|
||||||
|
def oc_interface_primary_mac_address(objectchange, reverting):
|
||||||
|
MACAddress = apps.get_model('dcim', 'MACAddress')
|
||||||
|
interface_ct = ContentType.objects.get_by_natural_key('dcim', 'interface')
|
||||||
|
|
||||||
|
# Swap data order if the change is being reverted
|
||||||
|
if not reverting:
|
||||||
|
before, after = objectchange.prechange_data, objectchange.postchange_data
|
||||||
|
else:
|
||||||
|
before, after = objectchange.postchange_data, objectchange.prechange_data
|
||||||
|
|
||||||
|
if after.get('mac_address') != before.get('mac_address'):
|
||||||
|
# Create & assign the new MACAddress (if any)
|
||||||
|
if after.get('mac_address'):
|
||||||
|
mac = MACAddress.objects.create(
|
||||||
|
mac_address=after['mac_address'],
|
||||||
|
assigned_object_type=interface_ct,
|
||||||
|
assigned_object_id=objectchange.changed_object_id,
|
||||||
|
)
|
||||||
|
after['primary_mac_address'] = mac.pk
|
||||||
|
else:
|
||||||
|
after['primary_mac_address'] = None
|
||||||
|
# Delete the old MACAddress (if any)
|
||||||
|
if before.get('mac_address'):
|
||||||
|
MACAddress.objects.filter(
|
||||||
|
mac_address=before['mac_address'],
|
||||||
|
assigned_object_type=interface_ct,
|
||||||
|
assigned_object_id=objectchange.changed_object_id,
|
||||||
|
).delete()
|
||||||
|
before['primary_mac_address'] = None
|
||||||
|
|
||||||
|
before.pop('mac_address', None)
|
||||||
|
after.pop('mac_address', None)
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'dcim.interface': oc_interface_primary_mac_address,
|
||||||
|
}
|
||||||
|
@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
|
|||||||
'gpu',
|
'gpu',
|
||||||
'hard_disk',
|
'hard_disk',
|
||||||
'memory',
|
'memory',
|
||||||
'power_supply'
|
'power_supply',
|
||||||
|
'expansion_card'
|
||||||
)
|
)
|
||||||
|
|
||||||
for name in initial_profiles:
|
for name in initial_profiles:
|
||||||
|
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0207_remove_redundant_indexes'),
|
||||||
|
('extras', '0129_fix_script_paths'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('parent', 'name'),
|
||||||
|
name='dcim_devicerole_parent_name'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('parent__isnull', True)),
|
||||||
|
fields=('name',),
|
||||||
|
name='dcim_devicerole_name',
|
||||||
|
violation_error_message='A top-level device role with this name already exists.'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('parent', 'slug'),
|
||||||
|
name='dcim_devicerole_parent_slug'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='devicerole',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('parent__isnull', True)),
|
||||||
|
fields=('slug',),
|
||||||
|
name='dcim_devicerole_slug',
|
||||||
|
violation_error_message='A top-level device role with this slug already exists.'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Expansion card",
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"connector_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Connector type e.g. PCIe x4"
|
||||||
|
},
|
||||||
|
"bandwidth": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Total Bandwidth for this module"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
constraints = (
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('parent', 'name'),
|
||||||
|
name='%(app_label)s_%(class)s_parent_name'
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('name',),
|
||||||
|
name='%(app_label)s_%(class)s_name',
|
||||||
|
condition=Q(parent__isnull=True),
|
||||||
|
violation_error_message=_("A top-level device role with this name already exists.")
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('parent', 'slug'),
|
||||||
|
name='%(app_label)s_%(class)s_parent_slug'
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('slug',),
|
||||||
|
name='%(app_label)s_%(class)s_slug',
|
||||||
|
condition=Q(parent__isnull=True),
|
||||||
|
violation_error_message=_("A top-level device role with this slug already exists.")
|
||||||
|
),
|
||||||
|
)
|
||||||
verbose_name = _('device role')
|
verbose_name = _('device role')
|
||||||
verbose_name_plural = _('device roles')
|
verbose_name_plural = _('device roles')
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ class CachedScopeMixin(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.scope_type and not self.scope:
|
if self.scope_type and not (self.scope or self.scope_id):
|
||||||
scope_type = self.scope_type.model_class()
|
scope_type = self.scope_type.model_class()
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'scope': _(
|
'scope': _(
|
||||||
|
@ -144,7 +144,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Validate any attributes against the assigned profile's schema
|
# Validate any attributes against the assigned profile's schema
|
||||||
if self.profile:
|
if self.profile and self.profile.schema:
|
||||||
try:
|
try:
|
||||||
jsonschema.validate(self.attribute_data, schema=self.profile.schema)
|
jsonschema.validate(self.attribute_data, schema=self.profile.schema)
|
||||||
except JSONValidationError as e:
|
except JSONValidationError as e:
|
||||||
|
@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
device_count = columns.LinkedCountColumn(
|
device_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:device_list',
|
viewname='dcim:device_list',
|
||||||
url_params={'role_id': 'pk'},
|
url_params={'role_id': 'pk'},
|
||||||
@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.DeviceRole
|
model = models.DeviceRole
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
|
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
|
||||||
'slug', 'tags', 'actions', 'created', 'last_updated',
|
'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
||||||
|
|
||||||
|
@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site_count = columns.LinkedCountColumn(
|
site_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:site_list',
|
viewname='dcim:site_list',
|
||||||
url_params={'region_id': 'pk'},
|
url_params={'region_id': 'pk'},
|
||||||
@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Region
|
model = Region
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||||
'created', 'last_updated', 'actions',
|
'created', 'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||||
@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site_count = columns.LinkedCountColumn(
|
site_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:site_list',
|
viewname='dcim:site_list',
|
||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = SiteGroup
|
model = SiteGroup
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||||
'created', 'last_updated', 'actions',
|
'created', 'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||||
@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
linkify=True,
|
||||||
|
)
|
||||||
site = tables.Column(
|
site = tables.Column(
|
||||||
verbose_name=_('Site'),
|
verbose_name=_('Site'),
|
||||||
linkify=True
|
linkify=True
|
||||||
@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Location
|
model = Location
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
|
||||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||||
'vlangroup_count',
|
'vlangroup_count',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
@ -14,7 +14,7 @@ from ipam.models import ASN, RIR, VLAN, VRF
|
|||||||
from netbox.api.serializers import GenericObjectSerializer
|
from netbox.api.serializers import GenericObjectSerializer
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
|
||||||
from virtualization.models import Cluster, ClusterType
|
from virtualization.models import Cluster, ClusterType
|
||||||
from wireless.choices import WirelessChannelChoices
|
from wireless.choices import WirelessChannelChoices
|
||||||
from wireless.models import WirelessLAN
|
from wireless.models import WirelessLAN
|
||||||
@ -1858,7 +1858,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||||||
|
|
||||||
# Attempt to delete only the parent interface
|
# Attempt to delete only the parent interface
|
||||||
url = self._get_detail_url(interface1)
|
url = self._get_detail_url(interface1)
|
||||||
self.client.delete(url, **self.header)
|
with disable_logging():
|
||||||
|
self.client.delete(url, **self.header)
|
||||||
self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
|
self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
|
||||||
|
|
||||||
# Attempt to bulk delete parent & child together
|
# Attempt to bulk delete parent & child together
|
||||||
|
@ -12,6 +12,7 @@ from users.models import User
|
|||||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
|
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
|
||||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||||
|
from wireless.models import WirelessLink
|
||||||
|
|
||||||
|
|
||||||
class DeviceComponentFilterSetTests:
|
class DeviceComponentFilterSetTests:
|
||||||
@ -4496,7 +4497,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
# Cables
|
# Cables
|
||||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
|
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
|
||||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
|
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
|
||||||
# Third pair is not connected
|
|
||||||
|
# Wireless links
|
||||||
|
WirelessLink(interface_a=interfaces[7], interface_b=interfaces[8]).save()
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Interface 1', 'Interface 2']}
|
params = {'name': ['Interface 1', 'Interface 2']}
|
||||||
@ -4684,15 +4687,15 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
|
|
||||||
def test_occupied(self):
|
def test_occupied(self):
|
||||||
params = {'occupied': True}
|
params = {'occupied': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
params = {'occupied': False}
|
params = {'occupied': False}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
def test_connected(self):
|
def test_connected(self):
|
||||||
params = {'connected': True}
|
params = {'connected': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
params = {'connected': False}
|
params = {'connected': False}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
def test_kind(self):
|
def test_kind(self):
|
||||||
params = {'kind': 'physical'}
|
params = {'kind': 'physical'}
|
||||||
|
@ -954,6 +954,19 @@ class CableTestCase(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
cable.clean()
|
cable.clean()
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_cable_cannot_terminate_to_a_cellular_interface(self):
|
||||||
|
"""
|
||||||
|
A cable cannot terminate to a cellular interface
|
||||||
|
"""
|
||||||
|
device1 = Device.objects.get(name='TestDevice1')
|
||||||
|
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||||
|
|
||||||
|
cellular_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_LTE)
|
||||||
|
cable = Cable(a_terminations=[interface2], b_terminations=[cellular_interface])
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
cable.clean()
|
||||||
|
|
||||||
|
|
||||||
class VirtualDeviceContextTestCase(TestCase):
|
class VirtualDeviceContextTestCase(TestCase):
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ from decimal import Decimal
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.test import override_settings
|
from django.test import override_settings, tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from netaddr import EUI
|
from netaddr import EUI
|
||||||
|
|
||||||
@ -1000,18 +1000,7 @@ inventory-items:
|
|||||||
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
# Blocked by absence of bulk import view for ModuleTypes
|
|
||||||
class ModuleTypeTestCase(
|
|
||||||
ViewTestCases.GetObjectViewTestCase,
|
|
||||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
|
||||||
ViewTestCases.CreateObjectViewTestCase,
|
|
||||||
ViewTestCases.EditObjectViewTestCase,
|
|
||||||
ViewTestCases.DeleteObjectViewTestCase,
|
|
||||||
ViewTestCases.ListObjectsViewTestCase,
|
|
||||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
|
||||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
|
||||||
):
|
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
|
|||||||
)
|
)
|
||||||
Manufacturer.objects.bulk_create(manufacturers)
|
Manufacturer.objects.bulk_create(manufacturers)
|
||||||
|
|
||||||
ModuleType.objects.bulk_create([
|
module_types = ModuleType.objects.bulk_create([
|
||||||
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
||||||
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
||||||
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
||||||
@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
|
|||||||
|
|
||||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
|
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'manufacturer': manufacturers[1].pk,
|
'manufacturer': manufacturers[1].pk,
|
||||||
'model': 'Device Type X',
|
'model': 'Device Type X',
|
||||||
@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
|
|||||||
'part_number': '456DEF',
|
'part_number': '456DEF',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
"manufacturer,model,part_number,comments,profile",
|
||||||
|
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
"id,model",
|
||||||
|
f"{module_types[0].id},test model",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_bulk_update_objects_with_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
# run base test
|
||||||
|
super().test_bulk_update_objects_with_permission()
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||||
|
def test_bulk_import_objects_with_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
# run base test
|
||||||
|
super().test_bulk_import_objects_with_permission()
|
||||||
|
|
||||||
|
# TODO: remove extra regression asserts once parent test supports testing all import fields
|
||||||
|
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
|
||||||
|
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||||
|
|
||||||
|
assert fan_module_type.profile == fan_module_type_profile
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||||
|
def test_bulk_import_objects_with_constrained_permission(self):
|
||||||
|
self.add_permissions(
|
||||||
|
'dcim.add_consoleporttemplate',
|
||||||
|
'dcim.add_consoleserverporttemplate',
|
||||||
|
'dcim.add_powerporttemplate',
|
||||||
|
'dcim.add_poweroutlettemplate',
|
||||||
|
'dcim.add_interfacetemplate',
|
||||||
|
'dcim.add_frontporttemplate',
|
||||||
|
'dcim.add_rearporttemplate',
|
||||||
|
'dcim.add_modulebaytemplate',
|
||||||
|
)
|
||||||
|
|
||||||
|
super().test_bulk_import_objects_with_constrained_permission()
|
||||||
|
|
||||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_moduletype_consoleports(self):
|
def test_moduletype_consoleports(self):
|
||||||
moduletype = ModuleType.objects.first()
|
moduletype = ModuleType.objects.first()
|
||||||
@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"name,slug,color",
|
"name,slug,color",
|
||||||
"Device Role 4,device-role-4,ff0000",
|
"Device Role 6,device-role-6,ff0000",
|
||||||
"Device Role 5,device-role-5,00ff00",
|
"Device Role 7,device-role-7,00ff00",
|
||||||
"Device Role 6,device-role-6,0000ff",
|
"Device Role 8,device-role-8,0000ff",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
|
|
||||||
|
|
||||||
def compile_path_node(ct_id, object_id):
|
def compile_path_node(ct_id, object_id):
|
||||||
@ -53,7 +53,7 @@ def rebuild_paths(terminations):
|
|||||||
for obj in terminations:
|
for obj in terminations:
|
||||||
cable_paths = CablePath.objects.filter(_nodes__contains=obj)
|
cable_paths = CablePath.objects.filter(_nodes__contains=obj)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(CablePath)):
|
||||||
for cp in cable_paths:
|
for cp in cable_paths:
|
||||||
cp.delete()
|
cp.delete()
|
||||||
create_cablepath(cp.origins)
|
create_cablepath(cp.origins)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@ -124,7 +124,7 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(Cable)):
|
||||||
count = 0
|
count = 0
|
||||||
cable_ids = set()
|
cable_ids = set()
|
||||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||||
@ -3746,7 +3746,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
|
|||||||
|
|
||||||
if vc_form.is_valid() and formset.is_valid():
|
if vc_form.is_valid() and formset.is_valid():
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(Device)):
|
||||||
|
|
||||||
# Save the VirtualChassis
|
# Save the VirtualChassis
|
||||||
vc_form.save()
|
vc_form.save()
|
||||||
|
@ -66,11 +66,11 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
|
||||||
def validate_schedule_at(self, value):
|
def validate_schedule_at(self, value):
|
||||||
if value and not self.context['script'].scheduling_enabled:
|
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_interval(self, value):
|
def validate_interval(self, value):
|
||||||
if value and not self.context['script'].scheduling_enabled:
|
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||||
return value
|
return value
|
||||||
|
@ -270,6 +270,7 @@ class ScriptViewSet(ModelViewSet):
|
|||||||
module_name, script_name = pk.split('.', maxsplit=1)
|
module_name, script_name = pk.split('.', maxsplit=1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
|
return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
|
||||||
|
|
||||||
def retrieve(self, request, pk):
|
def retrieve(self, request, pk):
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import functools
|
import functools
|
||||||
|
import operator
|
||||||
import re
|
import re
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Condition',
|
'Condition',
|
||||||
'ConditionSet',
|
'ConditionSet',
|
||||||
|
'InvalidCondition',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
AND = 'and'
|
AND = 'and'
|
||||||
OR = 'or'
|
OR = 'or'
|
||||||
|
|
||||||
@ -19,6 +20,10 @@ def is_ruleset(data):
|
|||||||
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCondition(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Condition:
|
class Condition:
|
||||||
"""
|
"""
|
||||||
An individual conditional rule that evaluates a single attribute and its value.
|
An individual conditional rule that evaluates a single attribute and its value.
|
||||||
@ -61,6 +66,7 @@ class Condition:
|
|||||||
|
|
||||||
self.attr = attr
|
self.attr = attr
|
||||||
self.value = value
|
self.value = value
|
||||||
|
self.op = op
|
||||||
self.eval_func = getattr(self, f'eval_{op}')
|
self.eval_func = getattr(self, f'eval_{op}')
|
||||||
self.negate = negate
|
self.negate = negate
|
||||||
|
|
||||||
@ -70,16 +76,17 @@ class Condition:
|
|||||||
"""
|
"""
|
||||||
def _get(obj, key):
|
def _get(obj, key):
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
return [dict.get(i, key) for i in obj]
|
return [operator.getitem(item or {}, key) for item in obj]
|
||||||
|
return operator.getitem(obj or {}, key)
|
||||||
return dict.get(obj, key)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||||
except TypeError:
|
except KeyError:
|
||||||
# Invalid key path
|
raise InvalidCondition(f"Invalid key path: {self.attr}")
|
||||||
value = None
|
try:
|
||||||
result = self.eval_func(value)
|
result = self.eval_func(value)
|
||||||
|
except TypeError as e:
|
||||||
|
raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
|
||||||
|
|
||||||
if self.negate:
|
if self.negate:
|
||||||
return not result
|
return not result
|
||||||
|
@ -192,5 +192,5 @@ def flush_events(events):
|
|||||||
try:
|
try:
|
||||||
func = import_string(name)
|
func = import_string(name)
|
||||||
func(events)
|
func(events)
|
||||||
except Exception as e:
|
except ImportError as e:
|
||||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||||
|
@ -238,10 +238,18 @@ class TagImportForm(CSVModelForm):
|
|||||||
label=_('Weight'),
|
label=_('Weight'),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
object_types = CSVMultipleContentTypeField(
|
||||||
|
label=_('Object types'),
|
||||||
|
queryset=ObjectType.objects.with_feature('tags'),
|
||||||
|
help_text=_("One or more assigned object types"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ('name', 'slug', 'color', 'weight', 'description')
|
fields = (
|
||||||
|
'name', 'slug', 'color', 'weight', 'description', 'object_types',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||||
|
@ -1,13 +1,8 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.files.storage import storages
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from core.choices import JobIntervalChoices
|
from core.choices import JobIntervalChoices
|
||||||
from core.forms import ManagedFileForm
|
from core.forms import ManagedFileForm
|
||||||
from extras.storage import ScriptFileSystemStorage
|
from django import forms
|
||||||
|
from django.core.files.storage import storages
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from utilities.datetime import local_now
|
from utilities.datetime import local_now
|
||||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||||
|
|
||||||
@ -74,12 +69,7 @@ class ScriptFileForm(ManagedFileForm):
|
|||||||
storage = storages.create_storage(storages.backends["scripts"])
|
storage = storages.create_storage(storages.backends["scripts"])
|
||||||
|
|
||||||
filename = self.cleaned_data['upload_file'].name
|
filename = self.cleaned_data['upload_file'].name
|
||||||
if isinstance(storage, ScriptFileSystemStorage):
|
self.instance.file_path = filename
|
||||||
full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
|
||||||
else:
|
|
||||||
full_path = filename
|
|
||||||
|
|
||||||
self.instance.file_path = full_path
|
|
||||||
data = self.cleaned_data['upload_file']
|
data = self.cleaned_data['upload_file']
|
||||||
storage.save(filename, data)
|
storage.save(filename, data)
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConfigContext, lookups=True)
|
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
|
||||||
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] = strawberry_django.filter_field()
|
name: FilterLookup[str] = strawberry_django.filter_field()
|
||||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||||
@ -97,7 +97,7 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ConfigTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
|
||||||
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -111,7 +111,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
|
|||||||
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CustomField, lookups=True)
|
@strawberry_django.filter_type(models.CustomField, lookups=True)
|
||||||
class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = (
|
type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -164,7 +164,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True)
|
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
|
||||||
class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -177,7 +177,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
|||||||
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
|
order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.CustomLink, lookups=True)
|
@strawberry_django.filter_type(models.CustomLink, lookups=True)
|
||||||
class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
@ -193,7 +193,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
|
new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ExportTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
|
||||||
class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -207,7 +207,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
|
|||||||
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ImageAttachment, lookups=True)
|
@strawberry_django.filter_type(models.ImageAttachment, lookups=True)
|
||||||
class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -222,7 +222,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.JournalEntry, lookups=True)
|
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
|
||||||
class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
||||||
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -238,7 +238,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag
|
|||||||
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
comments: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.NotificationGroup, lookups=True)
|
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
|
||||||
class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -246,7 +246,7 @@ class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.SavedFilter, lookups=True)
|
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
|
||||||
class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -263,7 +263,7 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.TableConfig, lookups=True)
|
@strawberry_django.filter_type(models.TableConfig, lookups=True)
|
||||||
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -276,13 +276,13 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
|||||||
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
|
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Tag, lookups=True)
|
@strawberry_django.filter_type(models.Tag, lookups=True)
|
||||||
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
|
class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
|
||||||
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Webhook, lookups=True)
|
@strawberry_django.filter_type(models.Webhook, lookups=True)
|
||||||
class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -301,7 +301,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.EventRule, lookups=True)
|
@strawberry_django.filter_type(models.EventRule, lookups=True)
|
||||||
class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
description: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
@ -39,6 +39,9 @@ class ScriptJob(JobRunner):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
|
# A script can modify multiple models so need to do an atomic lock on
|
||||||
|
# both the default database (for non ChangeLogged models) and potentially
|
||||||
|
# any other database (for ChangeLogged models)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
script.output = script.run(data, commit)
|
script.output = script.run(data, commit)
|
||||||
if not commit:
|
if not commit:
|
||||||
|
@ -18,9 +18,22 @@ class Empty(Lookup):
|
|||||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||||
|
|
||||||
|
|
||||||
|
class NetHost(Lookup):
|
||||||
|
"""
|
||||||
|
Similar to ipam.lookups.NetHost, but casts the field to INET.
|
||||||
|
"""
|
||||||
|
lookup_name = 'net_host'
|
||||||
|
|
||||||
|
def as_sql(self, qn, connection):
|
||||||
|
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||||
|
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||||
|
params = lhs_params + rhs_params
|
||||||
|
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
|
||||||
|
|
||||||
|
|
||||||
class NetContainsOrEquals(Lookup):
|
class NetContainsOrEquals(Lookup):
|
||||||
"""
|
"""
|
||||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
|
||||||
"""
|
"""
|
||||||
lookup_name = 'net_contains_or_equals'
|
lookup_name = 'net_contains_or_equals'
|
||||||
|
|
||||||
@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
|
|||||||
|
|
||||||
|
|
||||||
CharField.register_lookup(Empty)
|
CharField.register_lookup(Empty)
|
||||||
|
CachedValueField.register_lookup(NetHost)
|
||||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||||
|
56
netbox/extras/migrations/0129_fix_script_paths.py
Normal file
56
netbox/extras/migrations/0129_fix_script_paths.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import storages
|
||||||
|
from django.db import migrations
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from extras.storage import ScriptFileSystemStorage
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(url):
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if not parsed_url.path.endswith('/'):
|
||||||
|
return url + '/'
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def fix_script_paths(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Fix script paths for scripts that had incorrect path from NB 4.3.
|
||||||
|
"""
|
||||||
|
storage = storages.create_storage(storages.backends["scripts"])
|
||||||
|
if not isinstance(storage, ScriptFileSystemStorage):
|
||||||
|
return
|
||||||
|
|
||||||
|
ScriptModule = apps.get_model('extras', 'ScriptModule')
|
||||||
|
script_root_path = normalize(settings.SCRIPTS_ROOT)
|
||||||
|
for script in ScriptModule.objects.filter(file_path__startswith=script_root_path):
|
||||||
|
script.file_path = script.file_path[len(script_root_path):]
|
||||||
|
script.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0128_tableconfig'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(code=fix_script_paths, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_fix_script_paths(objectchange, reverting):
|
||||||
|
script_root_path = normalize(settings.SCRIPTS_ROOT)
|
||||||
|
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if file_path := data.get('file_path'):
|
||||||
|
if file_path.startswith(script_root_path):
|
||||||
|
data['file_path'] = file_path[len(script_root_path):]
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'extras.scriptmodule': oc_fix_script_paths,
|
||||||
|
}
|
@ -131,7 +131,7 @@ class RenderTemplateMixin(models.Model):
|
|||||||
"""
|
"""
|
||||||
context = self.get_context(context=context, queryset=queryset)
|
context = self.get_context(context=context, queryset=queryset)
|
||||||
env_params = self.environment_params or {}
|
env_params = self.environment_params or {}
|
||||||
output = render_jinja2(self.template_code, context, env_params)
|
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
|
||||||
|
|
||||||
# Replace CRLF-style line terminators
|
# Replace CRLF-style line terminators
|
||||||
output = output.replace('\r\n', '\n')
|
output = output.replace('\r\n', '\n')
|
||||||
|
@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
|
|||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.conditions import ConditionSet
|
from extras.conditions import ConditionSet, InvalidCondition
|
||||||
from extras.constants import *
|
from extras.constants import *
|
||||||
from extras.utils import image_upload
|
from extras.utils import image_upload
|
||||||
from extras.models.mixins import RenderTemplateMixin
|
from extras.models.mixins import RenderTemplateMixin
|
||||||
@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
|
|||||||
if not self.conditions:
|
if not self.conditions:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return ConditionSet(self.conditions).eval(data)
|
logger = logging.getLogger('netbox.event_rules')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ConditionSet(self.conditions).eval(data)
|
||||||
|
logger.debug(f'{self.name}: Evaluated as {result}')
|
||||||
|
return result
|
||||||
|
except InvalidCondition as e:
|
||||||
|
logger.error(f"{self.name}: Evaluation failed. {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
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
|
||||||
@ -144,6 +144,12 @@ class NotificationGroup(ChangeLoggedModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='notification_groups'
|
related_name='notification_groups'
|
||||||
)
|
)
|
||||||
|
event_rules = GenericRelation(
|
||||||
|
to='extras.EventRule',
|
||||||
|
content_type_field='action_object_type',
|
||||||
|
object_id_field='action_object_id',
|
||||||
|
related_query_name='+'
|
||||||
|
)
|
||||||
|
|
||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import datetime
|
|||||||
|
|
||||||
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, now
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
@ -991,6 +991,10 @@ class SubscriptionTest(APIViewTestCases.APIViewTestCase):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
cls.bulk_update_data = {
|
||||||
|
'user': users[3].pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
|
class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = NotificationGroup
|
model = NotificationGroup
|
||||||
@ -1072,6 +1076,9 @@ class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
|
|||||||
class NotificationTest(APIViewTestCases.APIViewTestCase):
|
class NotificationTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = Notification
|
model = Notification
|
||||||
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
|
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
|
||||||
|
bulk_update_data = {
|
||||||
|
'read': now(),
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
|||||||
from core.events import *
|
from core.events import *
|
||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.conditions import Condition, ConditionSet
|
from extras.conditions import Condition, ConditionSet, InvalidCondition
|
||||||
from extras.events import serialize_for_event
|
from extras.events import serialize_for_event
|
||||||
from extras.forms import EventRuleForm
|
from extras.forms import EventRuleForm
|
||||||
from extras.models import EventRule, Webhook
|
from extras.models import EventRule, Webhook
|
||||||
@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
|
|||||||
|
|
||||||
class ConditionTestCase(TestCase):
|
class ConditionTestCase(TestCase):
|
||||||
|
|
||||||
def test_dotted_path_access(self):
|
|
||||||
c = Condition('a.b.c', 1, 'eq')
|
|
||||||
self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
|
|
||||||
self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
|
|
||||||
self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
|
|
||||||
|
|
||||||
def test_undefined_attr(self):
|
def test_undefined_attr(self):
|
||||||
c = Condition('x', 1, 'eq')
|
c = Condition('x', 1, 'eq')
|
||||||
self.assertFalse(c.eval({}))
|
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({})
|
||||||
|
|
||||||
#
|
#
|
||||||
# Validation tests
|
# Validation tests
|
||||||
@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
|
|||||||
# dict type is unsupported
|
# dict type is unsupported
|
||||||
Condition('x', 1, dict())
|
Condition('x', 1, dict())
|
||||||
|
|
||||||
def test_invalid_op_type(self):
|
def test_invalid_op_types(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
# 'gt' supports only numeric values
|
# 'gt' supports only numeric values
|
||||||
Condition('x', 'foo', 'gt')
|
Condition('x', 'foo', 'gt')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
# 'in' supports only iterable values
|
||||||
|
Condition('x', 123, 'in')
|
||||||
|
|
||||||
#
|
#
|
||||||
# Nested attrs tests
|
# Nested attrs tests
|
||||||
@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x.y.z', 1)
|
c = Condition('x.y.z', 1)
|
||||||
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
||||||
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
||||||
self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': {'y': None}})
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': {'y': {'a': 1}}})
|
||||||
|
|
||||||
#
|
#
|
||||||
# Operator tests
|
# Operator tests
|
||||||
@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x', 1, 'gt')
|
c = Condition('x', 1, 'gt')
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertFalse(c.eval({'x': 1}))
|
self.assertFalse(c.eval({'x': 1}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_gte(self):
|
def test_gte(self):
|
||||||
c = Condition('x', 1, 'gte')
|
c = Condition('x', 1, 'gte')
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertFalse(c.eval({'x': 0}))
|
self.assertFalse(c.eval({'x': 0}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_lt(self):
|
def test_lt(self):
|
||||||
c = Condition('x', 2, 'lt')
|
c = Condition('x', 2, 'lt')
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertFalse(c.eval({'x': 2}))
|
self.assertFalse(c.eval({'x': 2}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_lte(self):
|
def test_lte(self):
|
||||||
c = Condition('x', 2, 'lte')
|
c = Condition('x', 2, 'lte')
|
||||||
self.assertTrue(c.eval({'x': 1}))
|
self.assertTrue(c.eval({'x': 1}))
|
||||||
self.assertTrue(c.eval({'x': 2}))
|
self.assertTrue(c.eval({'x': 2}))
|
||||||
self.assertFalse(c.eval({'x': 3}))
|
self.assertFalse(c.eval({'x': 3}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 'foo'}) # Invalid type
|
||||||
|
|
||||||
def test_in(self):
|
def test_in(self):
|
||||||
c = Condition('x', [1, 2, 3], 'in')
|
c = Condition('x', [1, 2, 3], 'in')
|
||||||
@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
|
|||||||
c = Condition('x', 1, 'contains')
|
c = Condition('x', 1, 'contains')
|
||||||
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
||||||
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
||||||
|
with self.assertRaises(InvalidCondition):
|
||||||
|
c.eval({'x': 123}) # Invalid type
|
||||||
|
|
||||||
def test_contains_negated(self):
|
def test_contains_negated(self):
|
||||||
c = Condition('x', 1, 'contains', negate=True)
|
c = Condition('x', 1, 'contains', negate=True)
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
from django.forms import ValidationError
|
import tempfile
|
||||||
from django.test import TestCase
|
from pathlib import Path
|
||||||
|
|
||||||
from core.models import ObjectType
|
from django.forms import ValidationError
|
||||||
|
from django.test import tag, TestCase
|
||||||
|
|
||||||
|
from core.models import DataSource, ObjectType
|
||||||
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, ConfigTemplate, Tag
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
@ -33,8 +36,8 @@ class TagTest(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
site = Site.objects.create(name='Site 1')
|
site = Site.objects.create(name='Site 1')
|
||||||
for tag in tags:
|
for _tag in tags:
|
||||||
site.tags.add(tag)
|
site.tags.add(_tag)
|
||||||
site.save()
|
site.save()
|
||||||
|
|
||||||
site = Site.objects.first()
|
site = Site.objects.first()
|
||||||
@ -540,3 +543,66 @@ class ConfigContextTest(TestCase):
|
|||||||
device.local_context_data = 'foo'
|
device.local_context_data = 'foo'
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
device.clean()
|
device.clean()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTemplateTest(TestCase):
|
||||||
|
"""
|
||||||
|
TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
|
||||||
|
"""
|
||||||
|
MAIN_TEMPLATE = """
|
||||||
|
{%- include 'base.j2' %}
|
||||||
|
""".strip()
|
||||||
|
BASE_TEMPLATE = """
|
||||||
|
Hi
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create_template_file(cls, templates_dir, file_name, content):
|
||||||
|
template_file_name = file_name
|
||||||
|
if not template_file_name.endswith('j2'):
|
||||||
|
template_file_name += '.j2'
|
||||||
|
temp_file_path = templates_dir / template_file_name
|
||||||
|
|
||||||
|
with open(temp_file_path, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
templates_dir = Path(temp_dir.name) / "templates"
|
||||||
|
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cls._create_template_file(templates_dir, 'base.j2', cls.BASE_TEMPLATE)
|
||||||
|
cls._create_template_file(templates_dir, 'main.j2', cls.MAIN_TEMPLATE)
|
||||||
|
|
||||||
|
data_source = DataSource(
|
||||||
|
name="Test DataSource",
|
||||||
|
type="local",
|
||||||
|
source_url=str(templates_dir),
|
||||||
|
)
|
||||||
|
data_source.save()
|
||||||
|
data_source.sync()
|
||||||
|
|
||||||
|
base_config_template = ConfigTemplate(
|
||||||
|
name="BaseTemplate",
|
||||||
|
data_file=data_source.datafiles.filter(path__endswith='base.j2').first()
|
||||||
|
)
|
||||||
|
base_config_template.clean()
|
||||||
|
base_config_template.save()
|
||||||
|
cls.base_config_template = base_config_template
|
||||||
|
|
||||||
|
main_config_template = ConfigTemplate(
|
||||||
|
name="MainTemplate",
|
||||||
|
data_file=data_source.datafiles.filter(path__endswith='main.j2').first()
|
||||||
|
)
|
||||||
|
main_config_template.clean()
|
||||||
|
main_config_template.save()
|
||||||
|
cls.main_config_template = main_config_template
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_config_template_with_data_source(self):
|
||||||
|
self.assertEqual(self.BASE_TEMPLATE, self.base_config_template.render({}))
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_config_template_with_data_source_nested_templates(self):
|
||||||
|
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
@ -7,6 +8,7 @@ from netaddr import IPAddress, IPNetwork
|
|||||||
|
|
||||||
from dcim.models import DeviceRole
|
from dcim.models import DeviceRole
|
||||||
from extras.scripts import *
|
from extras.scripts import *
|
||||||
|
from utilities.testing import disable_logging
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
('ff0000', 'Red'),
|
('ff0000', 'Red'),
|
||||||
@ -39,7 +41,8 @@ class ScriptTest(TestCase):
|
|||||||
datafile.write(bytes(YAML_DATA, 'UTF-8'))
|
datafile.write(bytes(YAML_DATA, 'UTF-8'))
|
||||||
datafile.seek(0)
|
datafile.seek(0)
|
||||||
|
|
||||||
data = Script().load_yaml(datafile.name)
|
with disable_logging(level=logging.WARNING):
|
||||||
|
data = Script().load_yaml(datafile.name)
|
||||||
self.assertEqual(data, {
|
self.assertEqual(data, {
|
||||||
'Foo': 123,
|
'Foo': 123,
|
||||||
'Bar': 456,
|
'Bar': 456,
|
||||||
@ -51,7 +54,8 @@ class ScriptTest(TestCase):
|
|||||||
datafile.write(bytes(JSON_DATA, 'UTF-8'))
|
datafile.write(bytes(JSON_DATA, 'UTF-8'))
|
||||||
datafile.seek(0)
|
datafile.seek(0)
|
||||||
|
|
||||||
data = Script().load_json(datafile.name)
|
with disable_logging(level=logging.WARNING):
|
||||||
|
data = Script().load_json(datafile.name)
|
||||||
self.assertEqual(data, {
|
self.assertEqual(data, {
|
||||||
'Foo': 123,
|
'Foo': 123,
|
||||||
'Bar': 456,
|
'Bar': 456,
|
||||||
|
@ -444,6 +444,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
|
||||||
tags = (
|
tags = (
|
||||||
Tag(name='Tag 1', slug='tag-1'),
|
Tag(name='Tag 1', slug='tag-1'),
|
||||||
Tag(name='Tag 2', slug='tag-2', weight=1),
|
Tag(name='Tag 2', slug='tag-2', weight=1),
|
||||||
@ -456,14 +458,15 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
'slug': 'tag-x',
|
'slug': 'tag-x',
|
||||||
'color': 'c0c0c0',
|
'color': 'c0c0c0',
|
||||||
'comments': 'Some comments',
|
'comments': 'Some comments',
|
||||||
|
'object_types': [site_ct.pk],
|
||||||
'weight': 11,
|
'weight': 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"name,slug,color,description,weight",
|
"name,slug,color,description,object_types,weight",
|
||||||
"Tag 4,tag-4,ff0000,Fourth tag,0",
|
"Tag 4,tag-4,ff0000,Fourth tag,dcim.interface,0",
|
||||||
"Tag 5,tag-5,00ff00,Fifth tag,1111",
|
"Tag 5,tag-5,00ff00,Fifth tag,'dcim.device,dcim.site',1111",
|
||||||
"Tag 6,tag-6,0000ff,Sixth tag,0",
|
"Tag 6,tag-6,0000ff,Sixth tag,dcim.site,0",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -966,7 +966,7 @@ class ObjectRenderConfigView(generic.ObjectView):
|
|||||||
|
|
||||||
# Render the config template
|
# Render the config template
|
||||||
rendered_config = None
|
rendered_config = None
|
||||||
error_message = None
|
error_message = ''
|
||||||
if config_template := instance.get_config_template():
|
if config_template := instance.get_config_template():
|
||||||
try:
|
try:
|
||||||
rendered_config = config_template.render(context=context_data)
|
rendered_config = config_template.render(context=context_data)
|
||||||
@ -1476,7 +1476,16 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
table = None
|
table = None
|
||||||
job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
|
job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
|
||||||
|
|
||||||
if job.completed:
|
# If a direct export output has been requested, return the job data content as a
|
||||||
|
# downloadable file.
|
||||||
|
if job.completed and request.GET.get('export') == 'output':
|
||||||
|
content = (job.data.get("output") or "").encode()
|
||||||
|
response = HttpResponse(content, content_type='text')
|
||||||
|
filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
elif job.completed:
|
||||||
table = self.get_table(job, request, bulk_actions=False)
|
table = self.get_table(job, request, bulk_actions=False)
|
||||||
|
|
||||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
||||||
|
@ -2,7 +2,7 @@ from copy import deepcopy
|
|||||||
|
|
||||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_pglocks import advisory_lock
|
from django_pglocks import advisory_lock
|
||||||
@ -295,7 +295,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
|||||||
|
|
||||||
# Create the new IP address(es)
|
# Create the new IP address(es)
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||||
created = serializer.save()
|
created = serializer.save()
|
||||||
self._validate_objects(created)
|
self._validate_objects(created)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
|
@ -449,7 +449,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
|
|||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||||
if vrf is None:
|
if vrf is None:
|
||||||
return queryset.none
|
return queryset.none()
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(vrf=vrf) |
|
Q(vrf=vrf) |
|
||||||
Q(vrf__export_targets__in=vrf.import_targets.all())
|
Q(vrf__export_targets__in=vrf.import_targets.all())
|
||||||
@ -729,7 +729,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
|
|||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||||
if vrf is None:
|
if vrf is None:
|
||||||
return queryset.none
|
return queryset.none()
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(vrf=vrf) |
|
Q(vrf=vrf) |
|
||||||
Q(vrf__export_targets__in=vrf.import_targets.all())
|
Q(vrf__export_targets__in=vrf.import_targets.all())
|
||||||
|
@ -633,7 +633,10 @@ class ServiceImportForm(NetBoxModelImportForm):
|
|||||||
# triggered
|
# triggered
|
||||||
parent = self.cleaned_data.get('parent')
|
parent = self.cleaned_data.get('parent')
|
||||||
for ip_address in self.cleaned_data.get('ipaddresses', []):
|
for ip_address in self.cleaned_data.get('ipaddresses', []):
|
||||||
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
if not (assigned := ip_address.assigned_object) or ( # no assigned object
|
||||||
|
(isinstance(parent, FHRPGroup) and assigned != parent) # assigned to FHRPGroup
|
||||||
|
and getattr(assigned, 'parent_object') != parent # assigned to [VM]Interface
|
||||||
|
):
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("{ip} is not assigned to this parent.").format(ip=ip_address)
|
_("{ip} is not assigned to this parent.").format(ip=ip_address)
|
||||||
)
|
)
|
||||||
|
@ -826,7 +826,7 @@ class ServiceForm(NetBoxModelForm):
|
|||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.instance and parent_object_type_id != self.instance.parent_object_type_id:
|
if self.instance and self.instance.pk and parent_object_type_id != self.instance.parent_object_type_id:
|
||||||
self.initial['parent'] = None
|
self.initial['parent'] = None
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
@ -11,10 +11,12 @@ from strawberry_django import FilterLookup, DateFilterLookup
|
|||||||
|
|
||||||
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
||||||
from dcim.graphql.filter_mixins import ScopedFilterMixin
|
from dcim.graphql.filter_mixins import ScopedFilterMixin
|
||||||
|
from dcim.models import Device
|
||||||
from ipam import models
|
from ipam import models
|
||||||
from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
|
from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
|
||||||
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
|
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
|
||||||
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||||
|
from virtualization.models import VMInterface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
|
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
|
||||||
@ -46,7 +48,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ASN, lookups=True)
|
@strawberry_django.filter_type(models.ASN, lookups=True)
|
||||||
class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
rir_id: ID | None = strawberry_django.filter_field()
|
rir_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -61,7 +63,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
|||||||
) = strawberry_django.filter_field()
|
) = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ASNRange, lookups=True)
|
@strawberry_django.filter_type(models.ASNRange, lookups=True)
|
||||||
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
|
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -75,7 +77,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Aggregate, lookups=True)
|
@strawberry_django.filter_type(models.Aggregate, lookups=True)
|
||||||
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
prefix_id: ID | None = strawberry_django.filter_field()
|
prefix_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -84,7 +86,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
|||||||
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
|
date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.FHRPGroup, lookups=True)
|
@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
|
||||||
class FHRPGroupFilter(PrimaryModelFilterMixin):
|
class FHRPGroupFilter(PrimaryModelFilterMixin):
|
||||||
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -102,7 +104,7 @@ class FHRPGroupFilter(PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True)
|
@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
|
||||||
class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
|
||||||
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -116,8 +118,32 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
|
|||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def device_id(self, queryset, value: list[str], prefix) -> Q:
|
||||||
|
return self.filter_device('id', value)
|
||||||
|
|
||||||
@strawberry_django.filter(models.IPAddress, lookups=True)
|
@strawberry_django.filter_field()
|
||||||
|
def device(self, value: list[str], prefix) -> Q:
|
||||||
|
return self.filter_device('name', value)
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def virtual_machine_id(self, value: list[str], prefix) -> Q:
|
||||||
|
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value))
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def virtual_machine(self, value: list[str], prefix) -> Q:
|
||||||
|
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value))
|
||||||
|
|
||||||
|
def filter_device(self, field, value) -> Q:
|
||||||
|
"""Helper to standardize logic for device and device_id filters"""
|
||||||
|
devices = Device.objects.filter(**{f'{field}__in': value})
|
||||||
|
interface_ids = []
|
||||||
|
for device in devices:
|
||||||
|
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||||
|
return Q(interface_id__in=interface_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry_django.filter_type(models.IPAddress, lookups=True)
|
||||||
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
address: FilterLookup[str] | None = strawberry_django.filter_field()
|
address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
@ -142,6 +168,10 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
|||||||
nat_outside_id: ID | None = strawberry_django.filter_field()
|
nat_outside_id: ID | None = strawberry_django.filter_field()
|
||||||
dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def assigned(self, value: bool, prefix) -> Q:
|
||||||
|
return Q(assigned_object_id__isnull=(not value))
|
||||||
|
|
||||||
@strawberry_django.filter_field()
|
@strawberry_django.filter_field()
|
||||||
def parent(self, value: list[str], prefix) -> Q:
|
def parent(self, value: list[str], prefix) -> Q:
|
||||||
if not value:
|
if not value:
|
||||||
@ -155,8 +185,16 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
|
|||||||
return Q()
|
return Q()
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
@strawberry_django.filter_field()
|
||||||
|
def family(
|
||||||
|
self,
|
||||||
|
value: Annotated['IPAddressFamilyEnum', strawberry.lazy('ipam.graphql.enums')],
|
||||||
|
prefix,
|
||||||
|
) -> Q:
|
||||||
|
return Q(**{f"{prefix}address__family": value.value})
|
||||||
|
|
||||||
@strawberry_django.filter(models.IPRange, lookups=True)
|
|
||||||
|
@strawberry_django.filter_type(models.IPRange, lookups=True)
|
||||||
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
@ -185,7 +223,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Prefix, lookups=True)
|
@strawberry_django.filter_type(models.Prefix, lookups=True)
|
||||||
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
|
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
@ -201,19 +239,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
|
|||||||
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
|
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RIR, lookups=True)
|
@strawberry_django.filter_type(models.RIR, lookups=True)
|
||||||
class RIRFilter(OrganizationalModelFilterMixin):
|
class RIRFilter(OrganizationalModelFilterMixin):
|
||||||
is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
|
is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Role, lookups=True)
|
@strawberry_django.filter_type(models.Role, lookups=True)
|
||||||
class RoleFilter(OrganizationalModelFilterMixin):
|
class RoleFilter(OrganizationalModelFilterMixin):
|
||||||
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.RouteTarget, lookups=True)
|
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
|
||||||
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||||
@ -230,7 +268,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.Service, lookups=True)
|
@strawberry_django.filter_type(models.Service, lookups=True)
|
||||||
class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
|
class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||||
@ -242,12 +280,12 @@ class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilt
|
|||||||
parent_object_id: ID | None = strawberry_django.filter_field()
|
parent_object_id: ID | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.ServiceTemplate, lookups=True)
|
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
|
||||||
class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
|
class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VLAN, lookups=True)
|
@strawberry_django.filter_type(models.VLAN, lookups=True)
|
||||||
class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
site_id: ID | None = strawberry_django.filter_field()
|
site_id: ID | None = strawberry_django.filter_field()
|
||||||
@ -277,19 +315,19 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VLANGroup, lookups=True)
|
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
|
||||||
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
|
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
|
||||||
vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True)
|
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
|
||||||
class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
|
class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VLANTranslationRule, lookups=True)
|
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
|
||||||
class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
|
class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
|
||||||
policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
@ -304,7 +342,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter(models.VRF, lookups=True)
|
@strawberry_django.filter_type(models.VRF, lookups=True)
|
||||||
class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
rd: FilterLookup[str] | None = strawberry_django.filter_field()
|
rd: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -44,3 +45,20 @@ class Migration(migrations.Migration):
|
|||||||
# Copy over existing site assignments
|
# Copy over existing site assignments
|
||||||
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
|
migrations.RunPython(code=copy_site_assignments, reverse_code=migrations.RunPython.noop),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_prefix_scope(objectchange, reverting):
|
||||||
|
site_ct = ContentType.objects.get_by_natural_key('dcim', 'site').pk
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if site_id := data.get('site'):
|
||||||
|
data.update({
|
||||||
|
'scope_type': site_ct,
|
||||||
|
'scope_id': site_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'ipam.prefix': oc_prefix_scope,
|
||||||
|
}
|
||||||
|
@ -60,3 +60,14 @@ class Migration(migrations.Migration):
|
|||||||
name='site',
|
name='site',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_prefix_remove_fields(objectchange, reverting):
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is not None:
|
||||||
|
data.pop('site', None)
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'ipam.prefix': oc_prefix_remove_fields,
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
|
||||||
@ -54,3 +55,26 @@ class Migration(migrations.Migration):
|
|||||||
reverse_code=repopulate_device_and_virtualmachine_relations,
|
reverse_code=repopulate_device_and_virtualmachine_relations,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_service_parent(objectchange, reverting):
|
||||||
|
device_ct = ContentType.objects.get_by_natural_key('dcim', 'device').pk
|
||||||
|
virtual_machine_ct = ContentType.objects.get_by_natural_key('virtualization', 'virtualmachine').pk
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
if device_id := data.get('device'):
|
||||||
|
data.update({
|
||||||
|
'parent_object_type': device_ct,
|
||||||
|
'parent_object_id': device_id,
|
||||||
|
})
|
||||||
|
elif virtual_machine_id := data.get('virtual_machine'):
|
||||||
|
data.update({
|
||||||
|
'parent_object_type': virtual_machine_ct,
|
||||||
|
'parent_object_id': virtual_machine_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'ipam.service': oc_service_parent,
|
||||||
|
}
|
||||||
|
@ -37,3 +37,15 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def oc_service_remove_fields(objectchange, reverting):
|
||||||
|
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||||
|
if data is not None:
|
||||||
|
data.pop('device', None)
|
||||||
|
data.pop('virtual_machine', None)
|
||||||
|
|
||||||
|
|
||||||
|
objectchange_migrators = {
|
||||||
|
'ipam.service': oc_service_remove_fields,
|
||||||
|
}
|
||||||
|
@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
|||||||
return self.prefix.version
|
return self.prefix.version
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.prefix and self.prefix.version == 6:
|
||||||
|
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
def get_child_prefixes(self):
|
def get_child_prefixes(self):
|
||||||
"""
|
"""
|
||||||
Return all Prefixes within this Aggregate
|
Return all Prefixes within this Aggregate
|
||||||
@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
|||||||
def mask_length(self):
|
def mask_length(self):
|
||||||
return self.prefix.prefixlen if self.prefix else None
|
return self.prefix.prefixlen if self.prefix else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.prefix and self.prefix.version == 6:
|
||||||
|
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def depth(self):
|
def depth(self):
|
||||||
return self._depth
|
return self._depth
|
||||||
@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
|||||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ipv6_full(self):
|
||||||
|
if self.address and self.address.version == 6:
|
||||||
|
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
|
||||||
|
|
||||||
def get_duplicates(self):
|
def get_duplicates(self):
|
||||||
return IPAddress.objects.filter(
|
return IPAddress.objects.filter(
|
||||||
vrf=self.vrf,
|
vrf=self.vrf,
|
||||||
|
@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet):
|
|||||||
|
|
||||||
# Find all relevant VLANGroups
|
# Find all relevant VLANGroups
|
||||||
q = Q()
|
q = Q()
|
||||||
site = vm.site or vm.cluster._site
|
site = vm.site
|
||||||
if vm.cluster:
|
if vm.cluster:
|
||||||
# Add VLANGroups scoped to the assigned cluster (or its group)
|
# Add VLANGroups scoped to the assigned cluster (or its group)
|
||||||
q |= Q(
|
q |= Q(
|
||||||
@ -160,6 +160,30 @@ class VLANQuerySet(RestrictedQuerySet):
|
|||||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
||||||
scope_id=vm.cluster.group_id
|
scope_id=vm.cluster.group_id
|
||||||
)
|
)
|
||||||
|
# Looking all possible cluster scopes
|
||||||
|
if vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'location'):
|
||||||
|
site = site or vm.cluster.scope.site
|
||||||
|
q |= Q(
|
||||||
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
|
||||||
|
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
|
||||||
|
)
|
||||||
|
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'site'):
|
||||||
|
site = site or vm.cluster.scope
|
||||||
|
q |= Q(
|
||||||
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||||
|
scope_id=vm.cluster.scope.pk
|
||||||
|
)
|
||||||
|
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'sitegroup'):
|
||||||
|
q |= Q(
|
||||||
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
|
||||||
|
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
|
||||||
|
)
|
||||||
|
elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'region'):
|
||||||
|
q |= Q(
|
||||||
|
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||||
|
scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
|
||||||
|
)
|
||||||
|
# VM can be assigned to a site without a cluster so checking assigned site independently
|
||||||
if site:
|
if site:
|
||||||
# Add VLANGroups scoped to the assigned site (or its group or region)
|
# Add VLANGroups scoped to the assigned site (or its group or region)
|
||||||
q |= Q(
|
q |= Q(
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from netaddr import IPNetwork
|
from netaddr import IPNetwork
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -9,7 +11,7 @@ from ipam.choices import *
|
|||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.data import string_to_ranges
|
from utilities.data import string_to_ranges
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
|
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
|
||||||
|
|
||||||
|
|
||||||
class AppTest(APITestCase):
|
class AppTest(APITestCase):
|
||||||
@ -382,6 +384,18 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
|||||||
)
|
)
|
||||||
Prefix.objects.bulk_create(prefixes)
|
Prefix.objects.bulk_create(prefixes)
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_clean_validates_scope(self):
|
||||||
|
prefix = Prefix.objects.first()
|
||||||
|
site = Site.objects.create(name='Test Site', slug='test-site')
|
||||||
|
|
||||||
|
data = {'scope_type': 'dcim.site', 'scope_id': site.id}
|
||||||
|
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
|
||||||
|
self.add_permissions('ipam.change_prefix')
|
||||||
|
|
||||||
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_list_available_prefixes(self):
|
def test_list_available_prefixes(self):
|
||||||
"""
|
"""
|
||||||
Test retrieval of all available prefixes within a parent prefix.
|
Test retrieval of all available prefixes within a parent prefix.
|
||||||
@ -1026,7 +1040,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
self.add_permissions('ipam.delete_vlan')
|
self.add_permissions('ipam.delete_vlan')
|
||||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
|
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
|
||||||
with disable_warnings('netbox.api.views.ModelViewSet'):
|
with disable_logging(level=logging.WARNING):
|
||||||
response = self.client.delete(url, **self.header)
|
response = self.client.delete(url, **self.header)
|
||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||||
|
@ -1849,6 +1849,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
|
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
|
||||||
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]),
|
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]),
|
||||||
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]),
|
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]),
|
||||||
|
Cluster(name='Cluster 4', type=cluster_type, group=cluster_groups[0], scope=locations[0]),
|
||||||
)
|
)
|
||||||
for cluster in clusters:
|
for cluster in clusters:
|
||||||
cluster.save()
|
cluster.save()
|
||||||
@ -1857,6 +1858,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
|
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
|
||||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
|
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
|
||||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]),
|
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2]),
|
||||||
|
VirtualMachine(name='Virtual Machine 4', cluster=clusters[3]),
|
||||||
)
|
)
|
||||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||||
|
|
||||||
@ -1864,6 +1866,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'),
|
VMInterface(virtual_machine=virtual_machines[0], name='VM Interface 1'),
|
||||||
VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'),
|
VMInterface(virtual_machine=virtual_machines[1], name='VM Interface 2'),
|
||||||
VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'),
|
VMInterface(virtual_machine=virtual_machines[2], name='VM Interface 3'),
|
||||||
|
VMInterface(virtual_machine=virtual_machines[3], name='VM Interface 4'),
|
||||||
)
|
)
|
||||||
VMInterface.objects.bulk_create(vm_interfaces)
|
VMInterface.objects.bulk_create(vm_interfaces)
|
||||||
|
|
||||||
@ -1890,6 +1893,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]),
|
VLANGroup(name='Cluster 1', slug='cluster-1', scope=clusters[0]),
|
||||||
VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]),
|
VLANGroup(name='Cluster 2', slug='cluster-2', scope=clusters[1]),
|
||||||
VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]),
|
VLANGroup(name='Cluster 3', slug='cluster-3', scope=clusters[2]),
|
||||||
|
VLANGroup(name='Cluster 4', slug='cluster-4', scope=clusters[3]),
|
||||||
|
|
||||||
# General purpose VLAN groups
|
# General purpose VLAN groups
|
||||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||||
@ -1944,11 +1948,12 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
VLAN(vid=19, name='Cluster 1', group=groups[18]),
|
VLAN(vid=19, name='Cluster 1', group=groups[18]),
|
||||||
VLAN(vid=20, name='Cluster 2', group=groups[19]),
|
VLAN(vid=20, name='Cluster 2', group=groups[19]),
|
||||||
VLAN(vid=21, name='Cluster 3', group=groups[20]),
|
VLAN(vid=21, name='Cluster 3', group=groups[20]),
|
||||||
|
VLAN(vid=22, name='Cluster 4', group=groups[21]),
|
||||||
VLAN(
|
VLAN(
|
||||||
vid=101,
|
vid=101,
|
||||||
name='VLAN 101',
|
name='VLAN 101',
|
||||||
site=sites[3],
|
site=sites[3],
|
||||||
group=groups[21],
|
group=groups[22],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
tenant=tenants[0],
|
tenant=tenants[0],
|
||||||
status=VLANStatusChoices.STATUS_ACTIVE,
|
status=VLANStatusChoices.STATUS_ACTIVE,
|
||||||
@ -1957,7 +1962,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vid=102,
|
vid=102,
|
||||||
name='VLAN 102',
|
name='VLAN 102',
|
||||||
site=sites[3],
|
site=sites[3],
|
||||||
group=groups[21],
|
group=groups[22],
|
||||||
role=roles[0],
|
role=roles[0],
|
||||||
tenant=tenants[0],
|
tenant=tenants[0],
|
||||||
status=VLANStatusChoices.STATUS_ACTIVE,
|
status=VLANStatusChoices.STATUS_ACTIVE,
|
||||||
@ -1966,7 +1971,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vid=201,
|
vid=201,
|
||||||
name='VLAN 201',
|
name='VLAN 201',
|
||||||
site=sites[4],
|
site=sites[4],
|
||||||
group=groups[22],
|
group=groups[23],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
tenant=tenants[1],
|
tenant=tenants[1],
|
||||||
status=VLANStatusChoices.STATUS_DEPRECATED,
|
status=VLANStatusChoices.STATUS_DEPRECATED,
|
||||||
@ -1975,7 +1980,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vid=202,
|
vid=202,
|
||||||
name='VLAN 202',
|
name='VLAN 202',
|
||||||
site=sites[4],
|
site=sites[4],
|
||||||
group=groups[22],
|
group=groups[23],
|
||||||
role=roles[1],
|
role=roles[1],
|
||||||
tenant=tenants[1],
|
tenant=tenants[1],
|
||||||
status=VLANStatusChoices.STATUS_DEPRECATED,
|
status=VLANStatusChoices.STATUS_DEPRECATED,
|
||||||
@ -1984,7 +1989,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vid=301,
|
vid=301,
|
||||||
name='VLAN 301',
|
name='VLAN 301',
|
||||||
site=sites[5],
|
site=sites[5],
|
||||||
group=groups[23],
|
group=groups[24],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
tenant=tenants[2],
|
tenant=tenants[2],
|
||||||
status=VLANStatusChoices.STATUS_RESERVED,
|
status=VLANStatusChoices.STATUS_RESERVED,
|
||||||
@ -1993,13 +1998,13 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vid=302,
|
vid=302,
|
||||||
name='VLAN 302',
|
name='VLAN 302',
|
||||||
site=sites[5],
|
site=sites[5],
|
||||||
group=groups[23],
|
group=groups[24],
|
||||||
role=roles[2],
|
role=roles[2],
|
||||||
tenant=tenants[2],
|
tenant=tenants[2],
|
||||||
status=VLANStatusChoices.STATUS_RESERVED,
|
status=VLANStatusChoices.STATUS_RESERVED,
|
||||||
),
|
),
|
||||||
# Create one globally available VLAN on a VLAN group
|
# Create one globally available VLAN on a VLAN group
|
||||||
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
|
VLAN(vid=500, name='VLAN Group 1', group=groups[25]),
|
||||||
# Create one globally available VLAN
|
# Create one globally available VLAN
|
||||||
VLAN(vid=1000, name='Global VLAN'),
|
VLAN(vid=1000, name='Global VLAN'),
|
||||||
# Create some Q-in-Q service VLANs
|
# Create some Q-in-Q service VLANs
|
||||||
@ -2130,6 +2135,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vm_id = VirtualMachine.objects.first().pk
|
vm_id = VirtualMachine.objects.first().pk
|
||||||
params = {'available_on_virtualmachine': vm_id}
|
params = {'available_on_virtualmachine': vm_id}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
||||||
|
vm_id = VirtualMachine.objects.get(name='Virtual Machine 4').pk
|
||||||
|
params = {'available_on_virtualmachine': vm_id}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) # 6 scoped + 1 global group + 1 global
|
||||||
|
|
||||||
def test_available_at_site(self):
|
def test_available_at_site(self):
|
||||||
site_id = Site.objects.first().pk
|
site_id = Site.objects.first().pk
|
||||||
|
@ -1068,6 +1068,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||||
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||||
|
fhrp_group = FHRPGroup.objects.create(
|
||||||
|
name='Group 1', group_id=1234, protocol=FHRPGroupProtocolChoices.PROTOCOL_CARP
|
||||||
|
)
|
||||||
|
|
||||||
services = (
|
services = (
|
||||||
Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||||
@ -1079,6 +1082,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
ip_addresses = (
|
ip_addresses = (
|
||||||
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||||
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||||
|
IPAddress(assigned_object=fhrp_group, address='192.0.2.3/24'),
|
||||||
)
|
)
|
||||||
IPAddress.objects.bulk_create(ip_addresses)
|
IPAddress.objects.bulk_create(ip_addresses)
|
||||||
|
|
||||||
@ -1100,6 +1104,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
"dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
"dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||||
"dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
"dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||||
"dcim.device,Device 1,Service 3,udp,3,,Third service",
|
"dcim.device,Device 1,Service 3,udp,3,,Third service",
|
||||||
|
"ipam.fhrpgroup,Group 1,Service 4,udp,4,192.0.2.3/24,Fourth service",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -2,7 +2,7 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.db.models import ProtectedError, RestrictedError
|
from django.db.models import ProtectedError, RestrictedError
|
||||||
from django_pglocks import advisory_lock
|
from django_pglocks import advisory_lock
|
||||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||||
@ -170,7 +170,7 @@ class NetBoxModelViewSet(
|
|||||||
|
|
||||||
# Enforce object-level permissions on save()
|
# Enforce object-level permissions on save()
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(model)):
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
self._validate_objects(instance)
|
self._validate_objects(instance)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
@ -190,7 +190,7 @@ class NetBoxModelViewSet(
|
|||||||
|
|
||||||
# Enforce object-level permissions on save()
|
# Enforce object-level permissions on save()
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(model)):
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
self._validate_objects(instance)
|
self._validate_objects(instance)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -56,22 +56,22 @@ class SequentialBulkCreatesMixin:
|
|||||||
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
|
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
|
||||||
appropriately.
|
appropriately.
|
||||||
"""
|
"""
|
||||||
@transaction.atomic
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
if not isinstance(request.data, list):
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||||
# Creating a single object
|
if not isinstance(request.data, list):
|
||||||
return super().create(request, *args, **kwargs)
|
# Creating a single object
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
return_data = []
|
return_data = []
|
||||||
for data in request.data:
|
for data in request.data:
|
||||||
serializer = self.get_serializer(data=data)
|
serializer = self.get_serializer(data=data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
self.perform_create(serializer)
|
self.perform_create(serializer)
|
||||||
return_data.append(serializer.data)
|
return_data.append(serializer.data)
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class BulkUpdateModelMixin:
|
class BulkUpdateModelMixin:
|
||||||
@ -113,7 +113,7 @@ class BulkUpdateModelMixin:
|
|||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def perform_bulk_update(self, objects, update_data, partial):
|
def perform_bulk_update(self, objects, update_data, partial):
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||||
data_list = []
|
data_list = []
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
data = update_data.get(obj.id)
|
data = update_data.get(obj.id)
|
||||||
@ -157,7 +157,7 @@ class BulkDestroyModelMixin:
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def perform_bulk_destroy(self, objects):
|
def perform_bulk_destroy(self, objects):
|
||||||
with transaction.atomic():
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
if hasattr(obj, 'snapshot'):
|
if hasattr(obj, 'snapshot'):
|
||||||
obj.snapshot()
|
obj.snapshot()
|
||||||
|
@ -231,14 +231,19 @@ SESSION_FILE_PATH = None
|
|||||||
# DISK_BASE_UNIT = 1024
|
# DISK_BASE_UNIT = 1024
|
||||||
# RAM_BASE_UNIT = 1024
|
# RAM_BASE_UNIT = 1024
|
||||||
|
|
||||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
# Within the STORAGES dictionary, "default" is used for image uploads, "staticfiles" is for static files and "scripts"
|
||||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
# is used for custom scripts. See django-storages and django-storage-swift libraries for more details. By default the
|
||||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
# following configuration is used:
|
||||||
# STORAGE_CONFIG = {
|
# STORAGES = {
|
||||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
# "default": {
|
||||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
# "BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
# },
|
||||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
# "staticfiles": {
|
||||||
|
# "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
# },
|
||||||
|
# "scripts": {
|
||||||
|
# "BACKEND": "extras.storage.ScriptFileSystemStorage",
|
||||||
|
# },
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Time zone (default: UTC)
|
# Time zone (default: UTC)
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user