mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-28 12:48:15 +01:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f867cb3ae0 | ||
|
|
a49fdad5e1 | ||
|
|
1ad029712e | ||
|
|
d87235af2f | ||
|
|
99af126fac | ||
|
|
adb9673f09 | ||
|
|
b693123f6e | ||
|
|
e7663b7e39 | ||
|
|
053be952ba | ||
|
|
390619ca99 | ||
|
|
b1130ff9b6 | ||
|
|
89fa546a14 | ||
|
|
c8988bac8a | ||
|
|
38a0ed5e24 | ||
|
|
12bb0ec1fe | ||
|
|
8b7ee0a0db | ||
|
|
ab3531558a | ||
|
|
164b2a5016 | ||
|
|
7b374e4cf6 | ||
|
|
c1c98f9883 | ||
|
|
dd8112c30e | ||
|
|
3c91331e16 | ||
|
|
eef38257b9 | ||
|
|
bb9a125934 | ||
|
|
8de252e34e | ||
|
|
9e305c6181 | ||
|
|
97ed6439ce | ||
|
|
6820796c10 | ||
|
|
4a331b560f | ||
|
|
4c9cf9032c | ||
|
|
ada01b39cc | ||
|
|
b41f8755df | ||
|
|
2c07762b7a | ||
|
|
278f2b173a | ||
|
|
768d6f624e | ||
|
|
1146aaff89 | ||
|
|
5a4feb7099 | ||
|
|
a6fd0ab09a | ||
|
|
9f71cf79e6 | ||
|
|
c26fe266cc | ||
|
|
085cfc58f4 | ||
|
|
63a0ec7a79 | ||
|
|
ccfdc216a5 | ||
|
|
2bf9acfb19 | ||
|
|
74d8baea30 | ||
|
|
f8d40ae824 | ||
|
|
41c92483a0 | ||
|
|
94c2a2e56c | ||
|
|
0a2ae90411 | ||
|
|
b032742418 | ||
|
|
8a684adf66 | ||
|
|
bca00cd97a | ||
|
|
2883fa14de | ||
|
|
3264636b7a | ||
|
|
fbc23424a6 | ||
|
|
6f08c4a4be | ||
|
|
0ac8419005 | ||
|
|
e467589730 | ||
|
|
0330c652bd | ||
|
|
5a6005cdfa | ||
|
|
4eaba7993f | ||
|
|
2840f9d71d | ||
|
|
9946ae2981 | ||
|
|
420ec6791f | ||
|
|
47234f1607 | ||
|
|
b058bd9cea | ||
|
|
5b03636c88 | ||
|
|
be55bb43ad | ||
|
|
293afab730 | ||
|
|
6b622fd9bf | ||
|
|
7280dfacab | ||
|
|
4428a446d0 | ||
|
|
2eedcac383 | ||
|
|
35af1d7b61 | ||
|
|
1b92958870 | ||
|
|
795669113f | ||
|
|
de57446f36 | ||
|
|
3b13cef0c8 | ||
|
|
497f3145fa | ||
|
|
f597b76ddc | ||
|
|
ebaac82560 | ||
|
|
371764fecd | ||
|
|
f67deb0dea | ||
|
|
d3c5f1e744 | ||
|
|
b12551c64b | ||
|
|
9995fad513 | ||
|
|
1d2335d578 | ||
|
|
ad03061edf | ||
|
|
87eabdbffb | ||
|
|
19787dd21d | ||
|
|
7d64e5bc62 | ||
|
|
a2c7452f90 | ||
|
|
654e32cbbe | ||
|
|
879aabe2f9 | ||
|
|
835af32213 | ||
|
|
571d33e660 | ||
|
|
96eb89a469 | ||
|
|
8d25d7812c | ||
|
|
a864e8127b | ||
|
|
fc482ed096 | ||
|
|
85f40bcbe0 | ||
|
|
e7ed280790 | ||
|
|
3d14a79428 | ||
|
|
d93a24d0bb | ||
|
|
4286d74d44 | ||
|
|
016eff52c0 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.6
|
||||
placeholder: v3.4.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/deprecation.yaml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/deprecation.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 🗑️ Deprecation
|
||||
description: The removal of an existing feature or resource
|
||||
labels: ["type: deprecation"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Changes
|
||||
description: >
|
||||
Describe in detail the proposed changes. What is being removed?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Justification
|
||||
description: Please provide justification for the proposed change(s).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Impact
|
||||
description: List all areas of the application that will be affected by this change.
|
||||
validations:
|
||||
required: true
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.6
|
||||
placeholder: v3.4.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
13
README.md
13
README.md
@@ -29,9 +29,18 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/netbox-community/netbox)
|
||||
|
||||
[](https://github.com/netbox-community/netbox-docker)
|
||||
|
||||
[](https://netboxlabs.com/netbox-cloud/)
|
||||
|
||||
</div>
|
||||
|
||||
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
|
||||
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
|
||||
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
|
||||
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
|
||||
|
||||
## Get Involved
|
||||
@@ -58,8 +67,6 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
[](https://netboxlabs.com)
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
[](https://ns1.com)
|
||||
<br />
|
||||
[](https://sentry.io)
|
||||
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach
|
||||
# https://github.com/mozilla/bleach/blob/main/CHANGES
|
||||
bleach<6.0
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
# https://docs.djangoproject.com/en/stable/releases/
|
||||
Django<4.2
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/OttoYiu/django-cors-headers
|
||||
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
|
||||
django-cors-headers
|
||||
|
||||
# Runtime UI tool for debugging Django
|
||||
# https://github.com/jazzband/django-debug-toolbar
|
||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||
django-debug-toolbar
|
||||
|
||||
# Library for writing reusable URL query filters
|
||||
# https://github.com/carltongibson/django-filter
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
django-filter
|
||||
|
||||
# Django debug toolbar extension with support for GraphiQL
|
||||
# https://github.com/flavors/django-graphiql-debug-toolbar/
|
||||
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
||||
# https://github.com/django-mptt/django-mptt
|
||||
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||
django-mptt
|
||||
|
||||
# Context managers for PostgreSQL advisory locks
|
||||
# https://github.com/Xof/django-pglocks
|
||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
||||
django-pglocks
|
||||
|
||||
# Prometheus metrics library for Django
|
||||
# https://github.com/korfuri/django-prometheus
|
||||
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
|
||||
django-prometheus
|
||||
|
||||
# Django caching backend using Redis
|
||||
# https://github.com/jazzband/django-redis
|
||||
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
|
||||
django-redis
|
||||
|
||||
# Django extensions for Rich (terminal text rendering)
|
||||
# https://github.com/adamchainz/django-rich
|
||||
# https://github.com/adamchainz/django-rich/blob/main/CHANGELOG.rst
|
||||
django-rich
|
||||
|
||||
# Django integration for RQ (Reqis queuing)
|
||||
# https://github.com/rq/django-rq
|
||||
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
|
||||
django-rq
|
||||
|
||||
# Abstraction models for rendering and paginating HTML tables
|
||||
# https://github.com/jieter/django-tables2
|
||||
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
|
||||
django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/alex/django-taggit
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
|
||||
# A Django field for representing time zones
|
||||
@@ -59,27 +59,27 @@ django-taggit
|
||||
django-timezone-field
|
||||
|
||||
# A REST API framework for Django projects
|
||||
# https://github.com/encode/django-rest-framework
|
||||
# https://www.django-rest-framework.org/community/release-notes/
|
||||
djangorestframework
|
||||
|
||||
# Swagger/OpenAPI schema generation for REST APIs
|
||||
# https://github.com/axnsan12/drf-yasg
|
||||
# https://drf-yasg.readthedocs.io/en/stable/changelog.html
|
||||
drf-yasg[validation]
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django
|
||||
# https://github.com/graphql-python/graphene-django/releases
|
||||
graphene_django
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://gunicorn.org/
|
||||
# https://docs.gunicorn.org/en/latest/news.html
|
||||
gunicorn
|
||||
|
||||
# Platform-agnostic template rendering engine
|
||||
# https://github.com/pallets/jinja
|
||||
# https://jinja.palletsprojects.com/changes/
|
||||
Jinja2
|
||||
|
||||
# Simple markup language for rendering HTML
|
||||
# https://github.com/Python-Markdown/markdown
|
||||
# https://python-markdown.github.io/change_log/
|
||||
# mkdocs currently requires Markdown v3.3
|
||||
Markdown<3.4
|
||||
|
||||
@@ -88,49 +88,50 @@ Markdown<3.4
|
||||
markdown-include
|
||||
|
||||
# MkDocs Material theme (for documentation build)
|
||||
# https://github.com/squidfunk/mkdocs-material
|
||||
# https://squidfunk.github.io/mkdocs-material/changelog/
|
||||
mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md
|
||||
mkdocstrings[python-legacy]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
|
||||
netaddr
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
# https://github.com/python-pillow/Pillow
|
||||
# https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst
|
||||
Pillow
|
||||
|
||||
# PostgreSQL database adapter for Python
|
||||
# https://github.com/psycopg/psycopg2
|
||||
# https://www.psycopg.org/docs/news.html
|
||||
psycopg2-binary
|
||||
|
||||
# YAML rendering library
|
||||
# https://github.com/yaml/pyyaml
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
PyYAML
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django
|
||||
social-auth-app-django
|
||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||
# See https://github.com/python-social-auth/social-app-django/issues/429
|
||||
social-auth-app-django==5.0.0
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite
|
||||
# hhttps://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
svgwrite
|
||||
|
||||
# Tabular dataset library (for table-based exports)
|
||||
# https://github.com/jazzband/tablib
|
||||
# https://github.com/jazzband/tablib/blob/master/HISTORY.md
|
||||
tablib
|
||||
|
||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||
# https://github.com/python/tzdata
|
||||
# https://github.com/python/tzdata/blob/master/NEWS.md
|
||||
tzdata
|
||||
|
||||
@@ -26,6 +26,8 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
|
||||
Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
|
||||
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
|
||||
```python
|
||||
|
||||
@@ -18,4 +18,4 @@ interface.
|
||||
|
||||
Default: False
|
||||
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.
|
||||
|
||||
@@ -45,6 +45,16 @@ Sets content for the top banner in the user interface.
|
||||
|
||||
---
|
||||
|
||||
## CENSUS_REPORTING_ENABLED
|
||||
|
||||
Default: True
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
@@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
|
||||
|
||||
Default: `'netbox.authentication.RemoteUserBackend'`
|
||||
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
|
||||
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given.
|
||||
|
||||
* `netbox.authentication.RemoteUserBackend`
|
||||
* `netbox.authentication.LDAPBackend`
|
||||
@@ -79,6 +79,30 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_EMAIL
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_EMAIL'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the email address of the currently authenticated user. For example, to use the request header `X-Remote-User-Email` it needs to be set to `HTTP_X_REMOTE_USER_EMAIL`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_FIRST_NAME
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_FIRST_NAME'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the first name of the currently authenticated user. For example, to use the request header `X-Remote-User-First-Name` it needs to be set to `HTTP_X_REMOTE_USER_FIRST_NAME`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_LAST_NAME
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_LAST_NAME'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the last name of the currently authenticated user. For example, to use the request header `X-Remote-User-Last-Name` it needs to be set to `HTTP_X_REMOTE_USER_LAST_NAME`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_SUPERUSER_GROUPS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
@@ -67,6 +67,12 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
|
||||
|
||||
---
|
||||
|
||||
## CSRF_COOKIE_SECURE
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## CSRF_TRUSTED_ORIGINS
|
||||
@@ -145,6 +151,17 @@ The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_SSL_REDIRECT
|
||||
|
||||
Default: False
|
||||
|
||||
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
|
||||
|
||||
!!! 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.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_NAME
|
||||
|
||||
Default: `sessionid`
|
||||
@@ -153,6 +170,14 @@ The name used for the session cookie. See the [Django documentation](https://doc
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_SECURE
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_FILE_PATH
|
||||
|
||||
Default: None
|
||||
|
||||
@@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port to use for the connection (default: `25`)
|
||||
* `USERNAME` - Username with which to authenticate
|
||||
* `PASSSWORD` - Password with which to authenticate
|
||||
* `PASSWORD` - Password with which to authenticate
|
||||
* `USE_SSL` - Use SSL when connecting to the server (default: `False`)
|
||||
* `USE_TLS` - Use TLS when connecting to the server (default: `False`)
|
||||
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
|
||||
|
||||
@@ -79,7 +79,22 @@ A human-friendly description of what your script does.
|
||||
|
||||
### `field_order`
|
||||
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
|
||||
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered within a default "Script Data" group. Any fields not included in this iterable be listed last. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the form by default for the user.
|
||||
|
||||
### `fieldsets`
|
||||
|
||||
`fieldsets` may be defined as an iterable of field groups and their field names to determine the order in which variables are group and rendered. Any fields not included in this iterable will not be displayed in the form. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the fieldsets by default for the user.
|
||||
|
||||
An example fieldset definition is provided below:
|
||||
|
||||
```python
|
||||
class MyScript(Script):
|
||||
class Meta:
|
||||
fieldsets = (
|
||||
('First group', ('field1', 'field2', 'field3')),
|
||||
('Second group', ('field4', 'field5')),
|
||||
)
|
||||
```
|
||||
|
||||
### `commit_default`
|
||||
|
||||
@@ -302,7 +317,7 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
|
||||
Scripts can be run on the CLI by invoking the management command:
|
||||
|
||||
```
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
```
|
||||
|
||||
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
|
||||
```postgresql
|
||||
CREATE DATABASE netbox;
|
||||
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
|
||||
ALTER DATABASE netbox OWNER TO netbox;
|
||||
```
|
||||
|
||||
!!! danger "Use a strong password"
|
||||
|
||||
@@ -584,11 +584,16 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
|
||||
|
||||
#### Client IP Restriction
|
||||
|
||||
!!! note
|
||||
This feature was introduced in NetBox v3.3.
|
||||
|
||||
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
|
||||
|
||||
#### Creating Tokens for Other Users
|
||||
|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||

|
||||
|
||||
!!! warning "Exercise Caution"
|
||||
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
|
||||
|
||||
### Authenticating to the API
|
||||
|
||||
@@ -633,7 +638,7 @@ $ curl -X POST \
|
||||
https://netbox/api/users/tokens/provision/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password": "I<3C3H8",
|
||||
"password": "I<3C3H8"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
BIN
docs/media/admin_ui_grant_permission.png
Normal file
BIN
docs/media/admin_ui_grant_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -1,5 +1,84 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.9 (2023-04-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10987](https://github.com/netbox-community/netbox/issues/10987) - Show peer racks as a dropdown list under rack view
|
||||
* [#11386](https://github.com/netbox-community/netbox/issues/11386) - Introduce `CSRF_COOKIE_SECURE`, `SECURE_SSL_REDIRECT`, and `SESSION_COOKIE_SECURE` configuration parameters
|
||||
* [#11623](https://github.com/netbox-community/netbox/issues/11623) - Hide PSK strings under wireless LAN & link views
|
||||
* [#12205](https://github.com/netbox-community/netbox/issues/12205) - Sanitize rendered custom links to mitigate malicious links
|
||||
* [#12226](https://github.com/netbox-community/netbox/issues/12226) - Enable setting user name & email values via remote authenticate headers
|
||||
* [#12337](https://github.com/netbox-community/netbox/issues/12337) - Enable anonymized reporting of census data
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11383](https://github.com/netbox-community/netbox/issues/11383) - Fix ordering of global search results by object type
|
||||
* [#11902](https://github.com/netbox-community/netbox/issues/11902) - Fix import of inventory items for devices with duplicated names
|
||||
* [#12238](https://github.com/netbox-community/netbox/issues/12238) - Improve error message for API token IP prefix validation failures
|
||||
* [#12255](https://github.com/netbox-community/netbox/issues/12255) - Restore the ability to move inventory items among devices
|
||||
* [#12270](https://github.com/netbox-community/netbox/issues/12270) - Fix pre-population of list values when creating a saved filter
|
||||
* [#12296](https://github.com/netbox-community/netbox/issues/12296) - Fix "mark connected" form field for bulk editing front & rear ports
|
||||
|
||||
---
|
||||
|
||||
## v3.4.8 (2023-04-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types
|
||||
* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group
|
||||
* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table
|
||||
* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation
|
||||
* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled
|
||||
* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
|
||||
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
|
||||
* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
|
||||
* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
|
||||
* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID
|
||||
* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
|
||||
* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
|
||||
* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
|
||||
* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
|
||||
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
|
||||
* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
|
||||
* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
|
||||
* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
|
||||
* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
|
||||
* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
|
||||
* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
|
||||
|
||||
---
|
||||
|
||||
## v3.4.7 (2023-03-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval
|
||||
* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms
|
||||
* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set
|
||||
* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter
|
||||
* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings
|
||||
* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters
|
||||
* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters
|
||||
* [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type
|
||||
* [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list
|
||||
* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates
|
||||
* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form
|
||||
* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority
|
||||
* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags
|
||||
* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts
|
||||
|
||||
---
|
||||
|
||||
## v3.4.6 (2023-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -8,6 +8,9 @@ theme:
|
||||
custom_dir: docs/_theme/
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
features:
|
||||
- content.code.copy
|
||||
- navigation.footer
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
@@ -20,7 +23,8 @@ theme:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
plugins:
|
||||
- search
|
||||
- search:
|
||||
lang: en
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
|
||||
@@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
commit_rate = CommitRateColumn(
|
||||
verbose_name='Commit Rate'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
|
||||
@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
filterset = filtersets.CircuitTypeFilterSet
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
|
||||
@@ -1137,7 +1137,6 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||
TYPE_2_8023AZ = 'type2-ieee802.3az'
|
||||
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||
|
||||
@@ -1152,7 +1151,6 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||
(TYPE_2_8023AZ, '802.3az (Type 2)'),
|
||||
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ __all__ = (
|
||||
'CableFilterSet',
|
||||
'CabledObjectFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'CommonInterfaceFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
'ConsolePortTemplateFilterSet',
|
||||
@@ -1321,11 +1322,63 @@ class PowerOutletFilterSet(
|
||||
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn',
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterSet(
|
||||
ModularDeviceComponentFilterSet,
|
||||
NetBoxModelFilterSet,
|
||||
CabledObjectFilterSet,
|
||||
PathEndpointFilterSet
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
# members
|
||||
@@ -1370,14 +1423,6 @@ class InterfaceFilterSet(
|
||||
poe_type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoETypeChoices
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
)
|
||||
vlan = django_filters.CharFilter(
|
||||
method='filter_vlan',
|
||||
label=_('Assigned VID')
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
@@ -1388,17 +1433,6 @@ class InterfaceFilterSet(
|
||||
rf_channel = django_filters.MultipleChoiceFilter(
|
||||
choices=WirelessChannelChoices
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
label=_('VRF'),
|
||||
)
|
||||
vrf = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vrf__rd',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
vdc_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
@@ -1416,17 +1450,6 @@ class InterfaceFilterSet(
|
||||
to_field_name='name',
|
||||
label='Virtual Device Context',
|
||||
)
|
||||
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn',
|
||||
queryset=L2VPN.objects.all(),
|
||||
label=_('L2VPN (ID)'),
|
||||
)
|
||||
l2vpn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='l2vpn_terminations__l2vpn__identifier',
|
||||
queryset=L2VPN.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label=_('L2VPN'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
@@ -1456,24 +1479,6 @@ class InterfaceFilterSet(
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def filter_vlan_id(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id=value) |
|
||||
Q(tagged_vlans=value)
|
||||
)
|
||||
|
||||
def filter_vlan(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(untagged_vlan_id__vid=value) |
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
def filter_kind(self, queryset, name, value):
|
||||
value = value.strip().lower()
|
||||
return {
|
||||
@@ -1662,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_a_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_a',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
termination_b_type = ContentTypeFilter(
|
||||
field_name='terminations__termination_type'
|
||||
)
|
||||
termination_b_id = MultiValueNumberFilter(
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
@@ -1725,6 +1732,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Supported objects: device, rack, location, site
|
||||
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
|
||||
|
||||
def filter_by_cable_end(self, queryset, name, value, side):
|
||||
# Filter by termination id and cable_end type
|
||||
return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
|
||||
|
||||
def filter_by_cable_end_a(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
|
||||
|
||||
def filter_by_cable_end_b(self, queryset, name, value):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = ModuleBay
|
||||
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'position', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
position_pattern = ExpandableNameField(
|
||||
position = ExpandableNameField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
|
||||
|
||||
@@ -1175,6 +1175,14 @@ class InterfaceBulkEditForm(
|
||||
},
|
||||
label=_('LAG')
|
||||
)
|
||||
vdcs = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
widget=SelectSpeedWidget(),
|
||||
@@ -1240,14 +1248,14 @@ class InterfaceBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
)
|
||||
@@ -1316,6 +1324,11 @@ class FrontPortBulkEditForm(
|
||||
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
|
||||
model = FrontPort
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
@@ -1327,6 +1340,11 @@ class RearPortBulkEditForm(
|
||||
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
|
||||
@@ -11,7 +11,9 @@ from dcim.models import *
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
@@ -667,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Parent LAG interface')
|
||||
)
|
||||
vdcs = CSVModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
help_text=_('Physical medium')
|
||||
@@ -706,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||
)
|
||||
|
||||
@@ -722,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
|
||||
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
|
||||
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
@@ -730,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
def clean_vdcs(self):
|
||||
for vdc in self.cleaned_data['vdcs']:
|
||||
if vdc.device != self.cleaned_data['device']:
|
||||
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
|
||||
return self.cleaned_data['vdcs']
|
||||
|
||||
|
||||
class FrontPortImportForm(NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
@@ -913,7 +928,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance'):
|
||||
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
|
||||
@@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -152,8 +152,6 @@ class Cable(PrimaryModel):
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError("Must specify a unit when setting a cable length")
|
||||
elif self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
if self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError("Must define A and B terminations when creating a new cable.")
|
||||
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
|
||||
else:
|
||||
self._abs_length = None
|
||||
|
||||
# Clear length_unit if no length is defined
|
||||
if self.length is None:
|
||||
self.length_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
|
||||
@@ -81,11 +81,25 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.device_type
|
||||
return objectchange
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device_type != self.device_type_id:
|
||||
raise ValidationError({
|
||||
"device_type": "Component templates cannot be moved to a different device type."
|
||||
})
|
||||
|
||||
|
||||
class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
"""
|
||||
|
||||
@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return f"{self.name} ({self.label})"
|
||||
@@ -88,6 +94,15 @@ class ComponentModel(NetBoxModel):
|
||||
objectchange.related_object = self.device
|
||||
return objectchange
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check list of Modules that allow device field to be changed
|
||||
if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
|
||||
raise ValidationError({
|
||||
"device": "Components cannot be moved to a different device."
|
||||
})
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.device
|
||||
@@ -794,8 +809,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({
|
||||
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
|
||||
})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
|
||||
# Validate channel width against interface type and selected channel (if any)
|
||||
if self.rf_channel_width:
|
||||
@@ -803,8 +816,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
|
||||
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
|
||||
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
|
||||
elif self.rf_channel:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
# VLAN validation
|
||||
|
||||
@@ -815,6 +826,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
f"interface's parent device, or it must be global."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set absolute channel attributes from selected options
|
||||
if self.rf_channel and not self.rf_channel_frequency:
|
||||
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
|
||||
if self.rf_channel and not self.rf_channel_width:
|
||||
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _occupied(self):
|
||||
return super()._occupied or bool(self.wireless_link_id)
|
||||
|
||||
@@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
blank=True
|
||||
)
|
||||
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
@@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
raise ValidationError({
|
||||
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
|
||||
})
|
||||
elif self.rack:
|
||||
self.location = self.rack.location
|
||||
|
||||
if self.rack is None:
|
||||
if self.face:
|
||||
@@ -776,8 +778,10 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||
(default). Otherwise, save() will be called on each instance individually.
|
||||
"""
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if components and bulk_create:
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
model = components[0]._meta.model
|
||||
model.objects.bulk_create(components)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
@@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
elif components:
|
||||
for component in components:
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
if is_new and not self.airflow:
|
||||
self.airflow = self.device_type.airflow
|
||||
|
||||
if self.rack and self.rack.location:
|
||||
self.location = self.rack.location
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If this is a new Device, instantiate all the related components per the DeviceType definition
|
||||
|
||||
@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
|
||||
raise ValidationError("Must specify a unit when setting an outer width/depth")
|
||||
elif self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
# Validate max_weight and weight_unit
|
||||
if self.max_weight and not self.weight_unit:
|
||||
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
else:
|
||||
self._abs_max_weight = None
|
||||
|
||||
# Clear unit if outer width & depth are not set
|
||||
if self.outer_width is None and self.outer_depth is None:
|
||||
self.outer_unit = ''
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=count_related(Rack, 'role')
|
||||
)
|
||||
filterset = filtersets.RackRoleFilterSet
|
||||
table = tables.RackRoleTable
|
||||
|
||||
|
||||
@@ -739,6 +740,7 @@ class RackView(generic.ObjectView):
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
'peer_racks': peer_racks,
|
||||
}
|
||||
|
||||
|
||||
@@ -909,6 +911,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer')
|
||||
)
|
||||
filterset = filtersets.ManufacturerFilterSet
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@@ -1808,6 +1811,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
vm_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
filterset = filtersets.DeviceRoleFilterSet
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
@@ -1868,6 +1872,7 @@ class PlatformBulkEditView(generic.BulkEditView):
|
||||
|
||||
class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
filterset = filtersets.PlatformFilterSet
|
||||
table = tables.PlatformTable
|
||||
|
||||
|
||||
@@ -2981,6 +2986,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
|
||||
|
||||
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
@@ -3038,6 +3044,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
inventoryitem_count=count_related(InventoryItem, 'role'),
|
||||
)
|
||||
filterset = filtersets.InventoryItemRoleFilterSet
|
||||
table = tables.InventoryItemRoleTable
|
||||
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance and self.instance.type != value:
|
||||
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
|
||||
|
||||
return value
|
||||
|
||||
def get_data_type(self, obj):
|
||||
types = CustomFieldTypeChoices
|
||||
if obj.type == types.TYPE_INTEGER:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import QueryDict
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
@@ -36,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
object_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
# TODO: Come up with a canonical way to register suitable models
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
|
||||
required=False,
|
||||
help_text=_("Type of the related object (for object/multi-object fields only)")
|
||||
)
|
||||
@@ -63,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'ui_visibility': StaticSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
|
||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
@@ -128,11 +137,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert any parameters delivered via initial data to a dictionary
|
||||
# Convert any parameters delivered via initial data to JSON data
|
||||
if initial and 'parameters' in initial:
|
||||
if type(initial['parameters']) is str:
|
||||
# TODO: Make a utility function for this
|
||||
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
|
||||
initial['parameters'] = json.loads(initial['parameters'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
@@ -254,6 +262,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
'tenants', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
# Convert data delivered via initial data to JSON data
|
||||
if initial and 'data' in initial:
|
||||
if type(initial['data']) is str:
|
||||
initial['data'] = json.loads(initial['data'])
|
||||
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
|
||||
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
@@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
def clean_schedule_at(self):
|
||||
def clean(self):
|
||||
scheduled_time = self.cleaned_data['schedule_at']
|
||||
if scheduled_time and scheduled_time < timezone.now():
|
||||
if scheduled_time and scheduled_time < local_now():
|
||||
raise forms.ValidationError(_('Scheduled time must be in the future.'))
|
||||
|
||||
return scheduled_time
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['interval'] and not scheduled_time:
|
||||
self.cleaned_data['schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
|
||||
# When interval is used without schedule at, raise an exception
|
||||
if self.cleaned_data['_interval'] and not scheduled_time:
|
||||
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
|
||||
self.cleaned_data['_schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, WebhooksMixin
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
class ConfigContext(CloningMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -108,6 +108,12 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||
'tenants', 'tags', 'data',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
@@ -28,7 +29,7 @@ from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
from utilities.utils import clean_html, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
@@ -245,7 +246,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -273,6 +274,18 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
link = render_jinja2(self.link_url, context)
|
||||
link_target = ' target="_blank"' if self.new_window else ''
|
||||
|
||||
# Sanitize link text
|
||||
allowed_schemes = get_config().ALLOWED_URL_SCHEMES
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote_plus(link, safe='/:?&')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
if result.scheme and result.scheme not in allowed_schemes:
|
||||
link = ""
|
||||
|
||||
return {
|
||||
'text': text,
|
||||
'link': link,
|
||||
@@ -280,7 +293,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
|
||||
}
|
||||
|
||||
|
||||
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='export_templates',
|
||||
@@ -301,7 +314,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='MIME type',
|
||||
help_text=_('Defaults to <code>text/plain</code>')
|
||||
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
|
||||
)
|
||||
file_extension = models.CharField(
|
||||
max_length=15,
|
||||
@@ -313,6 +326,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
help_text=_("Download file as attachment")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
|
||||
@@ -353,7 +370,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
Render the template to an HTTP response, delivered as a named file attachment
|
||||
"""
|
||||
output = self.render(queryset)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
|
||||
|
||||
# Build the response
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
@@ -406,7 +423,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
parameters = models.JSONField()
|
||||
|
||||
clone_fields = (
|
||||
'enabled', 'weight',
|
||||
'content_types', 'weight', 'enabled', 'parameters',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
|
||||
@@ -14,7 +14,7 @@ from utilities.fields import ColorField
|
||||
# Tags
|
||||
#
|
||||
|
||||
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
id = models.BigAutoField(
|
||||
primary_key=True
|
||||
)
|
||||
@@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'color', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@@ -352,6 +352,18 @@ class BaseScript:
|
||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
default_fieldset = (
|
||||
('Script Execution Parameters', ('_schedule_at', '_interval', '_commit')),
|
||||
)
|
||||
if not hasattr(self.Meta, 'fieldsets'):
|
||||
fields = (
|
||||
name for name, _ in self._get_vars().items()
|
||||
)
|
||||
self.Meta.fieldsets = (('Script Data', fields),)
|
||||
|
||||
self.Meta.fieldsets += default_fieldset
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -110,11 +112,14 @@ class SavedFilterTable(NetBoxTable):
|
||||
enabled = columns.BooleanColumn()
|
||||
shared = columns.BooleanColumn()
|
||||
|
||||
def value_parameters(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = SavedFilter
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
|
||||
'created', 'last_updated',
|
||||
'created', 'last_updated', 'parameters'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import datetime
|
||||
from unittest import skipIf
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
@@ -16,8 +13,6 @@ from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
rq_worker_running = Worker.count(get_connection('default'))
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
@@ -107,6 +102,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
update_data = {
|
||||
'content_types': ['dcim.device'],
|
||||
'name': 'New_Name',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -539,16 +539,6 @@ class ReportTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['name'], self.TestReport.__name__)
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_report(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
url = reverse('extras-api:report-run', kwargs={'pk': None})
|
||||
response = self.client.post(url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
@@ -589,27 +579,6 @@ class ScriptTest(APITestCase):
|
||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_script(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
script_data = {
|
||||
'var1': 'FooBar',
|
||||
'var2': 123,
|
||||
'var3': False,
|
||||
}
|
||||
|
||||
data = {
|
||||
'data': script_data,
|
||||
'commit': True,
|
||||
}
|
||||
|
||||
url = reverse('extras-api:script-detail', kwargs={'pk': None})
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.test import TestCase
|
||||
from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
@@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
|
||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||
self.assertIn(field_type, instance.custom_field_data)
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
"""
|
||||
Test form submission and validation
|
||||
"""
|
||||
form = SavedFilterForm({
|
||||
'name': 'test-sf',
|
||||
'slug': 'test-sf',
|
||||
'content_types': [ContentType.objects.get_for_model(Site).pk],
|
||||
'weight': 100,
|
||||
'parameters': {
|
||||
"status": [
|
||||
"active"
|
||||
]
|
||||
}
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
form.save()
|
||||
|
||||
@@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
table = tables.ConfigContextTable
|
||||
|
||||
|
||||
|
||||
@@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
|
||||
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
|
||||
'comments', 'tags')
|
||||
|
||||
|
||||
class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
if self.prefix:
|
||||
|
||||
# Clear host bits from prefix
|
||||
self.prefix = self.prefix.cidr
|
||||
|
||||
# /0 masks are not acceptable
|
||||
if self.prefix.prefixlen == 0:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -62,7 +62,7 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
url_name='ipam:routetarget_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
|
||||
@@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
self.add_permissions('ipam.delete_vlan')
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
|
||||
with disable_warnings('django.request'):
|
||||
with disable_warnings('netbox.api.views.ModelViewSet'):
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
|
||||
@@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
|
||||
|
||||
class RoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
filterset = filtersets.RoleFilterSet
|
||||
table = tables.RoleTable
|
||||
|
||||
|
||||
|
||||
@@ -193,6 +193,9 @@ PLUGINS = []
|
||||
REMOTE_AUTH_ENABLED = False
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER'
|
||||
REMOTE_AUTH_USER_FIRST_NAME = 'HTTP_REMOTE_USER_FIRST_NAME'
|
||||
REMOTE_AUTH_USER_LAST_NAME = 'HTTP_REMOTE_USER_LAST_NAME'
|
||||
REMOTE_AUTH_USER_EMAIL = 'HTTP_REMOTE_USER_EMAIL'
|
||||
REMOTE_AUTH_AUTO_CREATE_USER = True
|
||||
REMOTE_AUTH_DEFAULT_GROUPS = []
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS = {}
|
||||
|
||||
@@ -87,7 +87,17 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
|
||||
else:
|
||||
user = auth.authenticate(request, remote_user=username)
|
||||
if user:
|
||||
# User is valid. Set request.user and persist user in the session
|
||||
# User is valid.
|
||||
# Update the User's Profile if set by request headers
|
||||
if settings.REMOTE_AUTH_USER_FIRST_NAME in request.META:
|
||||
user.first_name = request.META[settings.REMOTE_AUTH_USER_FIRST_NAME]
|
||||
if settings.REMOTE_AUTH_USER_LAST_NAME in request.META:
|
||||
user.last_name = request.META[settings.REMOTE_AUTH_USER_LAST_NAME]
|
||||
if settings.REMOTE_AUTH_USER_EMAIL in request.META:
|
||||
user.email = request.META[settings.REMOTE_AUTH_USER_EMAIL]
|
||||
user.save()
|
||||
|
||||
# Set request.user and persist user in the session
|
||||
# by logging the user in.
|
||||
request.user = user
|
||||
auth.login(request, user)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
|
||||
"""
|
||||
super().clean()
|
||||
|
||||
for field in self._meta.get_fields():
|
||||
if isinstance(field, GenericForeignKey):
|
||||
ct_value = getattr(self, field.ct_field)
|
||||
fk_value = getattr(self, field.fk_field)
|
||||
|
||||
if ct_value is None and fk_value is not None:
|
||||
raise ValidationError({
|
||||
field.ct_field: "This field cannot be null.",
|
||||
})
|
||||
if fk_value is None and ct_value is not None:
|
||||
raise ValidationError({
|
||||
field.fk_field: "This field cannot be null.",
|
||||
})
|
||||
|
||||
if ct_value and fk_value:
|
||||
klass = getattr(self, field.ct_field).model_class()
|
||||
if not klass.objects.filter(pk=fk_value).exists():
|
||||
raise ValidationError({
|
||||
field.fk_field: f"Related object not found using the provided value: {fk_value}."
|
||||
})
|
||||
|
||||
|
||||
class PrimaryModel(NetBoxModel):
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
|
||||
@@ -111,7 +112,11 @@ class CloningMixin(models.Model):
|
||||
for field_name in getattr(self, 'clone_fields', []):
|
||||
field = self._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(self)
|
||||
if field_value not in (None, ''):
|
||||
if field_value and isinstance(field, models.ManyToManyField):
|
||||
attrs[field_name] = [v.pk for v in field_value]
|
||||
elif field_value and isinstance(field, models.JSONField):
|
||||
attrs[field_name] = json.dumps(field_value)
|
||||
elif field_value not in (None, ''):
|
||||
attrs[field_name] = field_value
|
||||
|
||||
# Include tags (if applicable)
|
||||
|
||||
@@ -3,9 +3,10 @@ import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import platform
|
||||
import requests
|
||||
import sys
|
||||
import warnings
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import django
|
||||
import sentry_sdk
|
||||
@@ -24,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.4.6'
|
||||
VERSION = '3.4.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -78,10 +79,12 @@ BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
|
||||
CENSUS_REPORTING_ENABLED = getattr(configuration, 'CENSUS_REPORTING_ENABLED', True)
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
|
||||
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
@@ -113,6 +116,9 @@ REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS'
|
||||
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
|
||||
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
|
||||
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
|
||||
REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
|
||||
REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
|
||||
REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_REMOTE_USER_EMAIL')
|
||||
REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
|
||||
REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
|
||||
REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
|
||||
@@ -124,6 +130,7 @@ REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 're
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||
@@ -131,6 +138,7 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
|
||||
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
|
||||
SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
|
||||
@@ -396,8 +404,10 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
# Set up authentication backends
|
||||
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
|
||||
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
REMOTE_AUTH_BACKEND,
|
||||
*REMOTE_AUTH_BACKEND,
|
||||
'netbox.authentication.ObjectPermissionBackend',
|
||||
]
|
||||
|
||||
@@ -491,6 +501,24 @@ if SENTRY_ENABLED:
|
||||
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
|
||||
|
||||
|
||||
#
|
||||
# Census collection
|
||||
#
|
||||
|
||||
CENSUS_URL = 'https://census.netbox.dev/api/v1/'
|
||||
CENSUS_PARAMS = {
|
||||
'version': VERSION,
|
||||
'python_version': sys.version.split()[0],
|
||||
'deployment_id': DEPLOYMENT_ID,
|
||||
}
|
||||
if CENSUS_REPORTING_ENABLED and not DEBUG and 'test' not in sys.argv:
|
||||
try:
|
||||
# Report anonymous census data
|
||||
requests.get(f'{CENSUS_URL}?{urlencode(CENSUS_PARAMS)}', timeout=3, proxies=HTTP_PROXIES)
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
|
||||
#
|
||||
# Django social auth
|
||||
#
|
||||
|
||||
@@ -200,7 +200,8 @@ class NetBoxTable(BaseTable):
|
||||
|
||||
class SearchTable(tables.Table):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
verbose_name=_('Type'),
|
||||
order_by="object___meta__verbose_name",
|
||||
)
|
||||
object = tables.Column(
|
||||
linkify=True
|
||||
|
||||
@@ -151,6 +151,29 @@ class ExternalAuthenticationTestCase(TestCase):
|
||||
self.assertEqual(int(self.client.session.get(
|
||||
'_auth_user_id')), self.user.pk, msg='Authentication failed')
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
LOGIN_REQUIRED=True
|
||||
)
|
||||
def test_remote_auth_user_profile(self):
|
||||
"""
|
||||
Test remote authentication with user profile details.
|
||||
"""
|
||||
headers = {
|
||||
'HTTP_REMOTE_USER': 'remoteuser1',
|
||||
'HTTP_REMOTE_USER_FIRST_NAME': 'John',
|
||||
'HTTP_REMOTE_USER_LAST_NAME': 'Smith',
|
||||
'HTTP_REMOTE_USER_EMAIL': 'johnsmith@example.com',
|
||||
}
|
||||
|
||||
response = self.client.get(reverse('home'), follow=True, **headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.user = User.objects.get(username='remoteuser1')
|
||||
self.assertEqual(self.user.first_name, "John", msg='User first name was not updated')
|
||||
self.assertEqual(self.user.last_name, "Smith", msg='User last name was not updated')
|
||||
self.assertEqual(self.user.email, "johnsmith@example.com", msg='User email was not updated')
|
||||
|
||||
@override_settings(
|
||||
REMOTE_AUTH_ENABLED=True,
|
||||
REMOTE_AUTH_AUTO_CREATE_USER=True,
|
||||
|
||||
@@ -16,7 +16,6 @@ from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
|
||||
@@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
]
|
||||
nullified_fields = request.POST.getlist('_nullify')
|
||||
updated_objects = []
|
||||
model_fields = {}
|
||||
m2m_fields = {}
|
||||
|
||||
# Build list of model fields and m2m fields for later iteration
|
||||
for name in standard_fields:
|
||||
try:
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
m2m_fields[name] = model_field
|
||||
else:
|
||||
model_fields[name] = model_field
|
||||
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_fields[name] = None
|
||||
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
|
||||
@@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
obj.snapshot()
|
||||
|
||||
# Update standard fields. If a field is listed in _nullify, delete its value.
|
||||
for name in standard_fields:
|
||||
|
||||
try:
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_field = None
|
||||
|
||||
for name, model_field in model_fields.items():
|
||||
# Handle nullification
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
if isinstance(model_field, ManyToManyField):
|
||||
getattr(obj, name).set([])
|
||||
else:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
# Normal fields
|
||||
elif name in form.changed_data:
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
@@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
obj.save()
|
||||
updated_objects.append(obj)
|
||||
|
||||
# Handle M2M fields after save
|
||||
for name, m2m_field in m2m_fields.items():
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
getattr(obj, name).clear()
|
||||
else:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
|
||||
# Add/remove tags
|
||||
if form.cleaned_data.get('add_tags', None):
|
||||
obj.tags.add(*form.cleaned_data['add_tags'])
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -56,4 +56,4 @@
|
||||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
import { initMarkdownPreviews } from './markdownPreview';
|
||||
import { initSecretToggle } from './secretToggle';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@@ -15,6 +16,7 @@ export function initButtons(): void {
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
initMarkdownPreviews,
|
||||
initSecretToggle,
|
||||
]) {
|
||||
func();
|
||||
}
|
||||
|
||||
77
netbox/project-static/src/buttons/secretToggle.ts
Normal file
77
netbox/project-static/src/buttons/secretToggle.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { secretState } from '../stores';
|
||||
import { getElement, getElements, isTruthy } from '../util';
|
||||
|
||||
import type { StateManager } from '../state';
|
||||
|
||||
type SecretState = { hidden: boolean };
|
||||
|
||||
/**
|
||||
* Change toggle button's text and attribute to reflect the current state.
|
||||
*
|
||||
* @param hidden `true` if the current state is hidden, `false` otherwise.
|
||||
* @param button Toggle element.
|
||||
*/
|
||||
function toggleSecretButton(hidden: boolean, button: HTMLButtonElement): void {
|
||||
button.setAttribute('data-secret-visibility', hidden ? 'hidden' : 'shown');
|
||||
button.innerText = hidden ? 'Show Secret' : 'Hide Secret';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show secret.
|
||||
*/
|
||||
function showSecret(): void {
|
||||
const secret = getElement('secret');
|
||||
if (isTruthy(secret)) {
|
||||
const value = secret.getAttribute('data-secret');
|
||||
if (isTruthy(value)) {
|
||||
secret.innerText = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide secret.
|
||||
*/
|
||||
function hideSecret(): void {
|
||||
const secret = getElement('secret');
|
||||
if (isTruthy(secret)) {
|
||||
const value = secret.getAttribute('data-secret');
|
||||
if (isTruthy(value)) {
|
||||
secret.innerText = '••••••••';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update secret state and visualization when the button is clicked.
|
||||
*
|
||||
* @param state State instance.
|
||||
* @param button Toggle element.
|
||||
*/
|
||||
function handleSecretToggle(state: StateManager<SecretState>, button: HTMLButtonElement): void {
|
||||
state.set('hidden', !state.get('hidden'));
|
||||
const hidden = state.get('hidden');
|
||||
|
||||
if (hidden) {
|
||||
hideSecret();
|
||||
} else {
|
||||
showSecret();
|
||||
}
|
||||
toggleSecretButton(hidden, button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize secret toggle button.
|
||||
*/
|
||||
export function initSecretToggle(): void {
|
||||
hideSecret();
|
||||
for (const button of getElements<HTMLButtonElement>('button.toggle-secret')) {
|
||||
button.addEventListener(
|
||||
'click',
|
||||
event => {
|
||||
handleSecretToggle(secretState, event.currentTarget as HTMLButtonElement);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './objectDepth';
|
||||
export * from './rackImages';
|
||||
export * from './previousPkCheck';
|
||||
export * from './secret';
|
||||
|
||||
6
netbox/project-static/src/stores/secret.ts
Normal file
6
netbox/project-static/src/stores/secret.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createState } from '../state';
|
||||
|
||||
export const secretState = createState<{ hidden: boolean }>(
|
||||
{ hidden: true },
|
||||
{ persist: true, key: 'netbox-secret' },
|
||||
);
|
||||
@@ -42,3 +42,9 @@ input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
// Remove x-axis padding from highlighted text
|
||||
mark {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
@@ -70,10 +70,17 @@ Blocks:
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.DEBUG and not settings.DEVELOPER %}
|
||||
<div class="alert alert-warning text-center mx-3" role="alert">
|
||||
<strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
|
||||
Performance may be limited. Debugging should never be enabled on a production system.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config.MAINTENANCE_MODE %}
|
||||
<div class="alert alert-warning text-center mx-3" role="alert">
|
||||
<h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
|
||||
<span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
|
||||
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
|
||||
NetBox is currently in maintenance mode. Functionality may be limited.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Account</th>
|
||||
<th scope="row">
|
||||
Account <i
|
||||
class="mdi mdi-alert-box text-warning"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="This field has been deprecated, and will be removed in NetBox v3.5."
|
||||
></i>
|
||||
</th>
|
||||
<td>{{ object.account|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<div class="card">
|
||||
<h5 class="card-header text-center">Comments</h5>
|
||||
<div class="card-body">
|
||||
{% render_field form.comments %}
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ vc_member.vc_priority|default:"" }}
|
||||
{{ vc_member.vc_priority|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,31 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
|
||||
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
|
||||
</a>
|
||||
<a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not next_rack %} disabled{% endif %}">
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i> Next
|
||||
</a>
|
||||
<div class="btn-group" role="group" aria-label="RackNavigation">
|
||||
{% if prev_rack %}
|
||||
<a href="{{ prev_rack.get_absolute_url }}" class="btn btn-sm btn-primary">
|
||||
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> {{ prev_rack }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if peer_racks %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{ object }}</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% for peer_rack in peer_racks %}
|
||||
<li><a class="dropdown-item{% if peer_rack.pk == object.pk %} active{% endif %}" href="{{ peer_rack.get_absolute_url }}">{{ peer_rack }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if next_rack %}
|
||||
<a href="{{ next_rack.get_absolute_url }}" class="btn btn-sm btn-primary">
|
||||
{{ next_rack }} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
{% render_field form.name %}
|
||||
{% render_field form.domain %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field vc_form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,16 +47,34 @@
|
||||
{% csrf_token %}
|
||||
<div class="field-group my-4">
|
||||
{% if form.requires_input %}
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Script Data</h5>
|
||||
</div>
|
||||
{% if script.Meta.fieldsets %}
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for group, fields in script.Meta.fieldsets %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">{{ group }}</h5>
|
||||
</div>
|
||||
{% for name in fields %}
|
||||
{% with field=form|getfield:name %}
|
||||
{% render_field field %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Render all fields as a single group #}
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Script Data</h5>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information"></i>
|
||||
This script does not require any input to run.
|
||||
</div>
|
||||
{% render_form form %}
|
||||
{% endif %}
|
||||
{% render_form form %}
|
||||
</div>
|
||||
<div class="float-end">
|
||||
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
|
||||
|
||||
@@ -15,12 +15,12 @@ Context:
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
|
||||
<button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" data-href="#tab_data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
|
||||
Data Import
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
|
||||
<button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" data-href="#tab_file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
|
||||
Upload File
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -85,7 +85,6 @@ Context:
|
||||
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -139,9 +139,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,9 +53,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">PSK</th>
|
||||
<td class="font-monospace">{{ object.auth_psk|placeholder }}</td>
|
||||
<td>
|
||||
<span id="secret" class="font-monospace" data-secret="{{ object.auth_psk }}">{{ object.auth_psk|placeholder }}</span>
|
||||
{% if object.auth_psk %}
|
||||
<button type="button" class="btn btn-sm btn-primary toggle-secret float-end" data-bs-toggle="button">Show Secret</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="field-group">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Side A</h5>
|
||||
</div>
|
||||
{% render_field form.device_a %}
|
||||
{% render_field form.interface_a %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="field-group">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Side B</h5>
|
||||
</div>
|
||||
{% render_field form.device_b %}
|
||||
{% render_field form.interface_b %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -84,6 +84,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
'tenant_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.TenantGroupFilterSet
|
||||
table = tables.TenantGroupTable
|
||||
|
||||
|
||||
@@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
'contact_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.ContactGroupFilterSet
|
||||
table = tables.ContactGroupTable
|
||||
|
||||
|
||||
@@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
|
||||
|
||||
class ContactRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ContactRole.objects.all()
|
||||
filterset = filtersets.ContactRoleFilterSet
|
||||
table = tables.ContactRoleTable
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
@@ -91,6 +92,16 @@ class TokenSerializer(ValidatedModelSerializer):
|
||||
data['key'] = Token.generate_key()
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# If the Token is being created on behalf of another user, enforce the grant_token permission.
|
||||
request = self.context.get('request')
|
||||
token_user = data.get('user')
|
||||
if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
|
||||
raise PermissionDenied("This user does not have permission to create tokens for other users.")
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class TokenProvisionSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ipam.formfields import IPNetworkFormField
|
||||
from ipam.validators import prefix_validator
|
||||
from netbox.preferences import PREFERENCES
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
|
||||
from utilities.utils import flatten_dict
|
||||
@@ -104,7 +105,7 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
|
||||
help_text=_("If no key is provided, one will be generated automatically.")
|
||||
)
|
||||
allowed_ips = SimpleArrayField(
|
||||
base_field=IPNetworkFormField(),
|
||||
base_field=IPNetworkFormField(validators=[prefix_validator]),
|
||||
required=False,
|
||||
label=_('Allowed IPs'),
|
||||
help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
|
||||
|
||||
@@ -12,7 +12,7 @@ class AppTest(APITestCase):
|
||||
def test_root(self):
|
||||
|
||||
url = reverse('users-api:api-root')
|
||||
response = self.client.get('{}?format=api'.format(url), **self.header)
|
||||
response = self.client.get(f'{url}?format=api', **self.header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
|
||||
'password': 'password6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'email': 'test@example.com',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
users = (
|
||||
User(username='User_1'),
|
||||
User(username='User_2'),
|
||||
User(username='User_3'),
|
||||
User(username='User_1', password='password1'),
|
||||
User(username='User_2', password='password2'),
|
||||
User(username='User_3', password='password3'),
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
|
||||
@@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Group.objects.bulk_create(users)
|
||||
|
||||
def test_bulk_update_objects(self):
|
||||
"""
|
||||
Disabled test. There's no attribute we can set in bulk for Groups.
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
class TokenTest(
|
||||
# No GraphQL support for Token
|
||||
@@ -144,6 +153,26 @@ class TokenTest(
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_provision_token_other_user(self):
|
||||
"""
|
||||
Test provisioning a Token for a different User with & without the grant_token permission.
|
||||
"""
|
||||
self.add_permissions('users.add_token')
|
||||
user2 = User.objects.create_user(username='testuser2')
|
||||
data = {
|
||||
'user': user2.id,
|
||||
}
|
||||
url = reverse('users-api:token-list')
|
||||
|
||||
# Attempt to create a new Token for User2 *without* the grant_token permission
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
# Assign grant_token permission and successfully create a new Token for User2
|
||||
self.add_permissions('users.grant_token')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
|
||||
class ObjectPermissionTest(
|
||||
# No GraphQL support for ObjectPermission
|
||||
|
||||
@@ -34,8 +34,8 @@ class CommentField(forms.CharField):
|
||||
Markdown</a> syntax is supported
|
||||
"""
|
||||
|
||||
def __init__(self, *, label='', help_text=help_text, required=False, **kwargs):
|
||||
super().__init__(label=label, help_text=help_text, required=required, **kwargs)
|
||||
def __init__(self, *, help_text=help_text, required=False, **kwargs):
|
||||
super().__init__(help_text=help_text, required=required, **kwargs)
|
||||
|
||||
|
||||
class SlugField(forms.SlugField):
|
||||
|
||||
@@ -198,8 +198,12 @@ def parse_csv(reader):
|
||||
header = header.strip()
|
||||
if '.' in header:
|
||||
field, to_field = header.split('.', 1)
|
||||
if field in headers:
|
||||
raise forms.ValidationError(f'Duplicate or conflicting column header for "{field}"')
|
||||
headers[field] = to_field
|
||||
else:
|
||||
if header in headers:
|
||||
raise forms.ValidationError(f'Duplicate or conflicting column header for "{header}"')
|
||||
headers[header] = None
|
||||
|
||||
# Parse CSV rows into a list of dictionaries mapped from the column headers.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
|
||||
<i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
|
||||
</a>
|
||||
{% if url %}
|
||||
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
|
||||
<i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
|
||||
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
|
||||
|
||||
{# Render the field label, except for: #}
|
||||
{# 1. Checkboxes (label appears to the right of the field #}
|
||||
{# 2. Textareas with no label set (will expand across entire row) #}
|
||||
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %}
|
||||
{% else %}
|
||||
{# Render the field label, except for checkboxes #}
|
||||
{% if field|widget_type != 'checkboxinput' %}
|
||||
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
@@ -20,6 +20,8 @@ def clone_button(instance):
|
||||
param_string = prepare_cloned_fields(instance).urlencode()
|
||||
if param_string:
|
||||
url = f'{url}?{param_string}'
|
||||
else:
|
||||
url = None
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
import decimal
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
from typing import Dict, Any
|
||||
|
||||
@@ -321,7 +322,7 @@ def applied_filters(context, model, form, query_params):
|
||||
save_link = None
|
||||
if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
|
||||
content_type = ContentType.objects.get_for_model(model).pk
|
||||
parameters = context['request'].GET.urlencode()
|
||||
parameters = json.dumps(dict(context['request'].GET.lists()))
|
||||
url = reverse('extras:savedfilter_add')
|
||||
save_link = f"{url}?content_types={content_type}¶meters={quote(parameters)}"
|
||||
|
||||
|
||||
@@ -319,6 +319,22 @@ class CSVDataFieldTest(TestCase):
|
||||
with self.assertRaises(forms.ValidationError):
|
||||
self.field.clean(input)
|
||||
|
||||
def test_duplicate_header(self):
|
||||
input = """
|
||||
status,status
|
||||
Active,Active
|
||||
"""
|
||||
with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
|
||||
self.field.clean(input)
|
||||
|
||||
def test_duplicate_header_key(self):
|
||||
input = """
|
||||
vrf.name,vrf.rd
|
||||
Test VRF,123:456
|
||||
"""
|
||||
with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
|
||||
self.field.clean(input)
|
||||
|
||||
def test_clean_default_to_field(self):
|
||||
input = """
|
||||
address,status,vrf.name
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user