Compare commits

..

80 Commits

Author SHA1 Message Date
jeremystretch
4452f57f90 Release v3.5-beta2 2023-04-18 09:21:15 -04:00
jeremystretch
b167153186 Enable scheduling_enabled parameter for reports 2023-04-18 08:45:31 -04:00
jeremystretch
197c6a1cbf Add scheduling_enabled parameter for scripts 2023-04-18 08:45:31 -04:00
jeremystretch
014a5d10d1 Closes #12187: Add button to download rendered config from UI 2023-04-17 16:37:02 -04:00
jeremystretch
86d185fe05 Fix validation of DataSource URL 2023-04-17 16:00:35 -04:00
Tobias Genannt
9ef1fb1e3a Use dulwich as Git client 2023-04-17 15:53:50 -04:00
jeremystretch
7ecf3be33c Clean up script & report job views 2023-04-17 15:27:12 -04:00
jeremystretch
a0893c2e8b Add general purpose view for individual jobs 2023-04-17 15:08:18 -04:00
Jeremy Stretch
8b040ff930 Closes #12129: Enable automatic synchronization of objects when DataFiles are updated (#12262)
* Closes #12129: Enable automatic synchronization of objects when DataFiles are updated

* Cleanup
2023-04-17 10:35:17 -04:00
Jeremy Stretch
d470848b29 Closes #12246: General cleanup of utilities modules
* Clean up base modules

* Clean up forms modules

* Clean up templatetags modules

* Replace custom simplify_decimal filter with floatformat

* Misc cleanup

* Merge ReturnURLForm into ConfirmationForm

* Clean up import statements for utilities.forms

* Fix field class references in docs
2023-04-14 10:33:53 -04:00
jeremystretch
59a6b3e71b Merge branch 'develop' into feature 2023-04-12 17:38:16 -04:00
jeremystretch
c1c98f9883 Closes #12232: Annotate direct URL for release notes for all projects 2023-04-12 17:30:00 -04:00
jeremystretch
dd8112c30e PRVB 2023-04-12 15:33:22 -04:00
Jeremy Stretch
3c91331e16 Merge pull request #12234 from netbox-community/develop
Release v3.4.8
2023-04-12 15:29:09 -04:00
jeremystretch
eef38257b9 Release v3.4.8 2023-04-12 14:06:09 -04:00
Austin de Coup-Crank
bb9a125934 Closes #12040: fix bulk import tab selection 2023-04-12 13:01:04 -04:00
Arthur Hanson
8de252e34e 11432 device field (#11567)
* 11432 make device field on interface read-only on api edit call

* 11432 make device field on interface read-only on api edit call

* 11432 extend serializer change to mixin

* 11432 add readonlydevicemixin to template serializers

* 11432 change subclass ordering

* 11432 fix device_type for template serializers

* 11432 DRY

* 11432 DRY

* 11432 make internal var

* 11432 change to model-level validation

* 11432 fix fk accessor

* Clean up validation error messages

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-04-12 10:25:54 -04:00
Arthur Hanson
9e305c6181 Closes #12207: Establish a permission for creating API tokens on behalf of other users (#12192)
* 11091 add permission to allow user to create api tokens for other users

* 11091 update docs

* 11091 fix for test

* 11091 fix for test

* 11091 test case for invalid token creation

* 11091 add test for permission grant

* Cleanup & fix serializer validation

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-04-12 10:25:06 -04:00
pobradovic08
97ed6439ce Fixes #12227: L2VPN Bulk import not setting Tenant field 2023-04-12 09:24:31 -04:00
jeremystretch
15a615d3a3 Optimize migrations 2023-04-11 16:08:41 -04:00
jeremystretch
3528aaee2c Update release notes 2023-04-11 15:56:34 -04:00
jeremystretch
46d7bf02ac Add tests for ConfigTemplate 2023-04-11 15:25:48 -04:00
jeremystretch
6820796c10 Closes #10414: Enable general purpose image attachments for device types 2023-04-10 16:43:40 -04:00
jeremystretch
4a331b560f Closes #11015: Remove unit from commit rate column header in circuits table 2023-04-10 16:35:21 -04:00
jeremystretch
4c9cf9032c Changelog for #10221, #10600, #11431, #11454 2023-04-10 14:06:36 -04:00
jeremystretch
ada01b39cc #10221: Tweak variable names & error message 2023-04-10 14:03:59 -04:00
Abhimanyu Saharan
b41f8755df Fixes GenericForeignKey validation (#11550)
* added model validation for GenericForeignKey

* added ct_field and fk_field null validation

* applied suggestion
2023-04-10 14:02:32 -04:00
Abhimanyu Saharan
2c07762b7a Added optional user and group on custom field (#12206)
* added group and user model to object_type

* Update netbox/utilities/utils.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-10 12:40:13 -04:00
jeremystretch
f68a63255b #11029: Cleanup & changelog 2023-04-10 10:40:56 -04:00
kkthxbye
278f2b173a Fixes #11431 - Disallow changing customfield type after creation (#11449)
* Disallow changing customfield type after creation

* Fix test_api.CustomFieldTest

---------

Co-authored-by: kkthxbye-code <>
2023-04-10 10:13:08 -04:00
Arthur Hanson
00714b23a2 11029 add changelog on CableTermination (#11407)
* 11209 add changelog on CableTermination

* Rename migration

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-04-10 10:11:58 -04:00
jeremystretch
768d6f624e Fixes #12191: Change absolute image path to relative 2023-04-10 09:17:13 -04:00
jeremystretch
1146aaff89 Closes #11453: Display a warning banner when DEBUG is enabled 2023-04-10 09:12:04 -04:00
Arthur Hanson
5a4feb7099 10615 filter cable termination_id with cable_end (#12182)
* 10615 filter cable termination_id with cable_end

* 10615 filter distinct

* 10615 filter distinct
2023-04-07 14:13:58 -04:00
jeremystretch
589d51e028 Merge branch 'develop' into feature 2023-04-07 13:58:56 -04:00
jeremystretch
a6fd0ab09a #12007: Move vlan & vlan_id filter methods to CommonInterfaceFilterSet 2023-04-07 13:58:12 -04:00
jeremystretch
08017c51f6 Merge branch 'develop' into feature 2023-04-07 13:00:00 -04:00
jeremystretch
9f71cf79e6 Changelog for #12007, #12118 2023-04-07 11:54:43 -04:00
Abhimanyu Saharan
c26fe266cc Moved interface filterset under common class (#12200)
* moved interface filterset under common class #12007

* lint fix
2023-04-07 10:54:39 -04:00
jeremystretch
085cfc58f4 Fixes #12184: Fix filtered bulk deletion for various models 2023-04-07 10:25:36 -04:00
jeremystretch
63a0ec7a79 Fixes #12190: Fix form layout for plugin textarea fields 2023-04-07 10:03:47 -04:00
jeremystretch
ccfdc216a5 Fixes #12118: Refactor bulk creation logic under _instantiate_components() 2023-04-06 17:01:46 -04:00
Jeremy Stretch
2bf9acfb19 Closes #12193: Clean up tests (#12197)
* Fix skipped API tests

* Remove invalid tests

* Correct logger name
2023-04-06 16:35:27 -04:00
jeremystretch
74d8baea30 Remove NS1 from sponsors list 2023-04-06 14:32:57 -04:00
jeremystretch
f8d40ae824 Changelog for #11746, #12011, #12087 2023-04-05 13:32:18 -04:00
Daniel Sheppard
41c92483a0 #12087 - Fix Bulk Edit update when M2M operations are present. (#12169)
* #12087 - Fix Bulk Edit update when M2M operations are present.

* #12087 - Minor tweaks

* Change .set() to .clear()

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* #12087 - Update comments

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-05 13:29:32 -04:00
jeremystretch
6d6299f0cb #11584: Linkify assigment counts in contacts table 2023-04-05 12:53:39 -04:00
jeremystretch
f44a2ba0ee Closes #12119: Lock & unlock dashboard layout 2023-04-05 09:59:22 -04:00
jeremystretch
29fbe6e4ee Closes #12126: Introduce a DEFAULT_DASHBOARD config parameter 2023-04-05 08:32:18 -04:00
Arthur Hanson
94c2a2e56c 11746 fix delete custom field (#12092)
* 11746 delete custom field

* 11746 use filter instead of exclude
2023-04-04 16:38:48 -04:00
Arthur
0a2ae90411 12011 fix module bay bulk create 2023-04-04 16:34:51 -04:00
jeremystretch
1b5f926e17 Fixes #12167: Catch and report on exceptions raised when rendering a config template 2023-04-04 08:47:01 -04:00
Arthur Hanson
13cbb33c98 12148 add swagger-sidecar for self-hosted swagger docs (#12159)
* 12148 add swagger-sidecar for self-hosted swagger docs

* 12149 add to base_requirements
2023-04-04 08:23:56 -04:00
jeremystretch
b032742418 Closes #12133: Move any instance mutations inside clean() to save() 2023-04-03 16:26:07 -04:00
jeremystretch
8a684adf66 Changelog for #12074, #12117 2023-04-03 15:38:05 -04:00
Arthur Hanson
bca00cd97a 12117 remove clone from cable (#12130)
* 12117 remove clone from cable

* 12117 remove clone button if no params

* Update clone.html

* Update clone.html

* Update clone.html

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-03 15:27:13 -04:00
Arthur Hanson
2883fa14de Fixes #12074: Move automatic location assignment out of clean()
* 12074 fix full clean

* 12074 move device location setting to save from clean

* 12074 fix set location only if present in rack

* Update base.py

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-03 15:22:36 -04:00
Arthur
56d2a9aa11 12151 remove choice field str mapping for OpenAPI 2023-04-03 15:09:34 -04:00
jeremystretch
53abcc0f5c Closes #12136: Extend object count & list widgets to support filters 2023-04-03 15:02:11 -04:00
jeremystretch
0676ed45c7 Closes #12107: Document support for plugin-provided dashboard widgets 2023-04-03 14:15:18 -04:00
jeremystretch
872b70c2b5 Fixes #12145: Employ HTMXSelect widget to fix inclusion of <select> field values during form regeneration 2023-04-03 12:49:26 -04:00
jeremystretch
2805633b16 Fixes #12144: Ensure consistent treatment of context data when rendering config templates via UI & API 2023-04-03 09:50:07 -04:00
jeremystretch
f245f07fd9 Fixes #12146: Do not display object selector for disabled fields 2023-04-03 09:39:03 -04:00
kkthxbye
e966d1df47 Merge pull request #12139 from tobiasge/fix-data-source-logs
Fix #12138: Log correct count when files were deleted
2023-04-02 22:29:59 +02:00
kkthxbye
e4b2d87ce6 Merge pull request #12143 from tobiasge/fix-missing-s3backend
Fixes #12142: Add S3Backend to __all__
2023-04-02 22:28:08 +02:00
kkthxbye
b3a347e6fb Merge pull request #12141 from tobiasge/fix-typo-in-datasource
Fixes: #12140: Typo in Synchronized Data documentation
2023-04-02 22:24:41 +02:00
Tobias Genannt
e7d1a43541 Fixes #12142: Add S3Backend to __all__ 2023-04-01 11:28:33 +02:00
Tobias Genannt
5ff9483d13 Log correct count when files were deleted 2023-04-01 10:51:33 +02:00
Tobias Genannt
ac07b33602 Fixed typo in Synchronized Data documentation 2023-04-01 10:45:45 +02:00
jeremystretch
8d6c591535 Fixes #12103: Limit the types of objects available for object count & list widgets 2023-03-31 15:30:40 -04:00
jeremystretch
6a85c5b3ce Fixes #12115: Fix rendering config templates from a data file 2023-03-31 10:37:12 -04:00
jeremystretch
bd38b50e5e Fixes #12105: Prevent data sources from becoming stuck in syncing status when an exception is raised 2023-03-31 09:52:07 -04:00
jeremystretch
eb77c0e920 Fixes #12106: Fix exception when saving dashboard widget with minimum width/height 2023-03-31 09:34:50 -04:00
jeremystretch
198c004c1d Fixes #12108: Limit the draggable area of widgets to their headers 2023-03-31 09:26:58 -04:00
jeremystretch
730eb2e83b Fixes #12112: Do not link data source URL for local paths 2023-03-31 09:06:16 -04:00
jeremystretch
cdad50e051 Fixes #12109: Fix migration error when replicating more than 100 job results 2023-03-31 08:59:15 -04:00
jeremystretch
3264636b7a Changelog for #12084, #12095 2023-03-30 17:02:23 -04:00
gdprdatasubect
fbc23424a6 Update models.py
Change default MIME-Type as discussed under #12095
2023-03-30 16:58:41 -04:00
jeremystretch
6f08c4a4be Fixes #11846: Update database creation instructions for PostgreSQL 14+ 2023-03-30 16:35:56 -04:00
jeremystretch
0ac8419005 Fixes #12104: Restore copy-to-clipboard & footer navigation in docs 2023-03-30 16:29:54 -04:00
190 changed files with 1857 additions and 893 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.7
placeholder: v3.5-beta2
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.7
placeholder: v3.5-beta2
validations:
required: true
- type: dropdown

View File

@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

View File

@@ -1,61 +1,61 @@
# HTML sanitizer
# https://github.com/mozilla/bleach
# https://github.com/mozilla/bleach/blob/main/CHANGES
bleach<6.0
# Python client for Amazon AWS API
# https://github.com/boto/boto3
# https://github.com/boto/boto3/blob/develop/CHANGELOG.rst
boto3
# 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
@@ -63,31 +63,39 @@ 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
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
drf-spectacular
# Serve self-contained distribution builds of Swagger UI and Redoc with Django.
# https://github.com/tfranzel/drf-spectacular-sidecar
drf-spectacular-sidecar
# Git client for file sync
# https://github.com/jelmer/dulwich/releases
dulwich
# RSS feed parser
# https://github.com/kurtmckee/feedparser
# https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst
feedparser
# 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
@@ -96,50 +104,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
# 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

View File

@@ -1,5 +1,50 @@
# Default Value Parameters
## DEFAULT_DASHBOARD
This parameter controls the content and layout of user's default dashboard. Once the dashboard has been created, the user is free to customize it as they please by adding, removing, and reconfiguring widgets.
This parameter must specify an iterable of dictionaries, each representing a discrete dashboard widget and its configuration. The follow widget attributes are supported:
* `widget`: Dotted path to the Python class (required)
* `width`: Default widget width (between 1 and 12, inclusive)
* `height`: Default widget height, in rows
* `title`: Widget title
* `color`: Color of the widget's title bar, specified by name
* `config`: Dictionary mapping of any widget configuration parameters
A brief example configuration is provided below.
```python
DEFAULT_DASHBOARD = [
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 2,
'title': 'Organization',
'config': {
'models': [
'dcim.site',
'tenancy.tenant',
'tenancy.contact',
]
}
},
{
'widget': 'extras.ObjectCountsWidget',
'title': 'IPAM',
'color': 'blue',
'config': {
'models': [
'ipam.prefix',
'ipam.iprange',
'ipam.ipaddress',
]
}
},
]
```
## DEFAULT_USER_PREFERENCES
!!! tip "Dynamic Configuration Parameter"

View File

@@ -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.

View File

@@ -104,6 +104,10 @@ The checkbox to commit database changes when executing a script is checked by de
commit_default = False
```
### `scheduling_enabled`
By default, a script can be scheduled for execution at a later time. Setting `scheduling_enabled` to False disables this ability: Only immediate execution will be possible. (This also disables the ability to set a recurring execution interval.)
### `job_timeout`
Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used.

View File

@@ -91,6 +91,10 @@ As you can see, reports are completely customizable. Validation logic can be as
A human-friendly description of what your report does.
### `scheduling_enabled`
By default, a report can be scheduled for execution at a later time. Setting `scheduling_enabled` to False disables this ability: Only immediate execution will be possible. (This also disables the ability to set a recurring execution interval.)
### `job_timeout`
Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
@@ -130,7 +134,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.
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
![Adding the run action to a permission](../media/admin_ui_run_permission.png)
### Via the Web UI

View File

@@ -20,4 +20,4 @@ The following NetBox models can be associated with replicated data files:
* Config templates
* Export templates
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stgae process ensures that automated synchronization tasks do not immediately affect production data.
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.

View File

@@ -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"

View File

@@ -586,6 +586,15 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
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.
![Adding the grant action to a permission](../media/admin_ui_grant_permission.png)
!!! 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
An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,52 @@
# Dashboard Widgets
!!! note "Introduced in v3.5"
Support for custom dashboard widgets was introduced in NetBox v3.5.
Each NetBox user can customize his or her personal dashboard by adding and removing widgets and by manipulating the size and position of each. Plugins can register their own dashboard widgets to complement those already available natively.
## The DashboardWidget Class
All dashboard widgets must inherit from NetBox's `DashboardWidget` base class. Subclasses must provide a `render()` method, and may override the base class' default characteristics.
Widgets which require configuration by a user must also include a `ConfigForm` child class which inherits from `WidgetConfigForm`. This form is used to render the user configuration options for the widget.
::: extras.dashboard.widgets.DashboardWidget
## Widget Registration
To register a dashboard widget for use in NetBox, import the `register_widget()` decorator and use it to wrap each `DashboardWidget` subclass:
```python
from extras.dashboard.widgets import DashboardWidget, register_widget
@register_widget
class MyWidget1(DashboardWidget):
...
@register_widget
class MyWidget2(DashboardWidget):
...
```
## Example
```python
from django import forms
from extras.dashboard.utils import register_widget
from extras.dashboard.widgets import DashboardWidget, WidgetConfigForm
@register_widget
class ReminderWidget(DashboardWidget):
default_title = 'Reminder'
description = 'Add a virtual sticky note'
class ConfigForm(WidgetConfigForm):
content = forms.CharField(
widget=forms.Textarea()
)
def render(self, request):
return self.config.get('content')
```

View File

@@ -145,23 +145,23 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
::: utilities.forms.ColorField
::: utilities.forms.fields.ColorField
options:
members: false
::: utilities.forms.CommentField
::: utilities.forms.fields.CommentField
options:
members: false
::: utilities.forms.JSONField
::: utilities.forms.fields.JSONField
options:
members: false
::: utilities.forms.MACAddressField
::: utilities.forms.fields.MACAddressField
options:
members: false
::: utilities.forms.SlugField
::: utilities.forms.fields.SlugField
options:
members: false
@@ -170,52 +170,52 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
!!! warning "Obsolete Fields"
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
::: utilities.forms.ChoiceField
::: utilities.forms.fields.ChoiceField
options:
members: false
::: utilities.forms.MultipleChoiceField
::: utilities.forms.fields.MultipleChoiceField
options:
members: false
## Dynamic Object Fields
::: utilities.forms.DynamicModelChoiceField
::: utilities.forms.fields.DynamicModelChoiceField
options:
members: false
::: utilities.forms.DynamicModelMultipleChoiceField
::: utilities.forms.fields.DynamicModelMultipleChoiceField
options:
members: false
## Content Type Fields
::: utilities.forms.ContentTypeChoiceField
::: utilities.forms.fields.ContentTypeChoiceField
options:
members: false
::: utilities.forms.ContentTypeMultipleChoiceField
::: utilities.forms.fields.ContentTypeMultipleChoiceField
options:
members: false
## CSV Import Fields
::: utilities.forms.CSVChoiceField
::: utilities.forms.fields.CSVChoiceField
options:
members: false
::: utilities.forms.CSVMultipleChoiceField
::: utilities.forms.fields.CSVMultipleChoiceField
options:
members: false
::: utilities.forms.CSVModelChoiceField
::: utilities.forms.fields.CSVModelChoiceField
options:
members: false
::: utilities.forms.CSVContentTypeField
::: utilities.forms.fields.CSVContentTypeField
options:
members: false
::: utilities.forms.CSVMultipleContentTypeField
::: utilities.forms.fields.CSVMultipleContentTypeField
options:
members: false

View File

@@ -1,6 +1,38 @@
# NetBox v3.4
## v3.4.8 (FUTURE)
## v3.4.9 (FUTURE)
---
## 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
---

View File

@@ -1,9 +1,10 @@
# NetBox v3.5
## v3.5-beta1 (2023-03-30)
## v3.5-beta2 (2023-04-18)
### Breaking Changes
* The `account` field has been removed from the provider model. This information is now tracked using the new provider account model. Multiple accounts can be assigned per provider.
* The JobResult model has been moved from the `extras` app to `core` and renamed to Job. Accordingly, its REST API endpoint has been moved from `/api/extras/job-results/` to `/api/core/jobs/`.
* The `obj_type` field on the Job model (previously JobResult) has been renamed to `object_type` for consistency with other models.
* The `JOBRESULT_RETENTION` configuration parameter has been renamed to `JOB_RETENTION`.
@@ -54,7 +55,9 @@ Two new webhook trigger events have been introduced: `job_start` and `job_end`.
* [#10242](https://github.com/netbox-community/netbox/issues/10242) - Redirect to filtered objects list after bulk import
* [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group
* [#10729](https://github.com/netbox-community/netbox/issues/10729) - Add date & time custom field type
* [#11029](https://github.com/netbox-community/netbox/issues/11029) - Enable change logging for cable terminations
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
* [#11255](https://github.com/netbox-community/netbox/issues/11255) - Introduce the `scheduling_enabled` settings for reports & scripts
* [#11291](https://github.com/netbox-community/netbox/issues/11291) - Optimized GraphQL API request handling
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
* [#11494](https://github.com/netbox-community/netbox/issues/11494) - Enable filtering objects by create/update request IDs
@@ -68,11 +71,27 @@ Two new webhook trigger events have been introduced: `job_start` and `job_end`.
* [#12068](https://github.com/netbox-community/netbox/issues/12068) - Enable generic foreign key relationships from jobs to NetBox objects
* [#12085](https://github.com/netbox-community/netbox/issues/12085) - Add a file source view for reports
### Bug Fixes (From Beta1)
* [#12103](https://github.com/netbox-community/netbox/issues/12103) - Limit the types of objects available for object count & list widgets
* [#12105](https://github.com/netbox-community/netbox/issues/12105) - Prevent data sources from becoming stuck in "syncing" status when an exception is raised
* [#12106](https://github.com/netbox-community/netbox/issues/12106) - Fix exception when saving dashboard widget with minimum width/height
* [#12108](https://github.com/netbox-community/netbox/issues/12108) - Limit the draggable area of widgets to their headers
* [#12109](https://github.com/netbox-community/netbox/issues/12109) - Fix migration error when replicating more than 100 job results
* [#12112](https://github.com/netbox-community/netbox/issues/12112) - Do not link data source URL for local paths
* [#12115](https://github.com/netbox-community/netbox/issues/12115) - Fix rendering config templates from a data file
* [#12144](https://github.com/netbox-community/netbox/issues/12144) - Ensure consistent treatment of context data when rendering config templates via UI & API
* [#12145](https://github.com/netbox-community/netbox/issues/12145) - Employ `HTMXSelect` widget to fix inclusion of `<select>` field values during form regeneration
* [#12146](https://github.com/netbox-community/netbox/issues/12146) - Do not display object selector for disabled fields
* [#12151](https://github.com/netbox-community/netbox/issues/12151) - Remove incorrect OpenAPI string mapping for choice fields
* [#12167](https://github.com/netbox-community/netbox/issues/12167) - Catch and report on exceptions raised when rendering a config template
### Other Changes
* [#9608](https://github.com/netbox-community/netbox/issues/9608) - Upgrade REST API schema to OpenAPI 3.0
* [#10604](https://github.com/netbox-community/netbox/issues/10604) - Remove unused `extra_tabs` block from `object.html` generic template
* [#10923](https://github.com/netbox-community/netbox/issues/10923) - Remove unused `NetBoxModelCSVForm` class (replaced by `NetBoxModelImportForm`)
* [#11489](https://github.com/netbox-community/netbox/issues/11489) - Consoldated several middleware classes
* [#11489](https://github.com/netbox-community/netbox/issues/11489) - Consolidated several middleware classes
* [#11611](https://github.com/netbox-community/netbox/issues/11611) - Refactor API viewset classes and introduce NetBoxReadOnlyModelViewSet
* [#11694](https://github.com/netbox-community/netbox/issues/11694) - Remove obsolete `SmallTextarea` form widget
* [#11737](https://github.com/netbox-community/netbox/issues/11737) - `ChangeLoggedModel` now inherits `WebhooksMixin`
@@ -96,6 +115,8 @@ Two new webhook trigger events have been introduced: `job_start` and `job_end`.
* Added the optional `account` foreign key to ProviderAccount
* circuits.Provider
* Removed the `account` field
* dcim.CableTermination
* Added `default_platform` foreign key (optional)
* dcim.DeviceType
* Added `default_platform` foreign key (optional)
* dcim.InterfaceTemplate

View File

@@ -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:
@@ -137,6 +141,7 @@ nav:
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Dashboard Widgets: 'plugins/development/dashboard-widgets.md'
- Staged Changes: 'plugins/development/staged-changes.md'
- Exceptions: 'plugins/development/exceptions.md'
- Administration:

View File

@@ -6,9 +6,9 @@ from circuits.models import *
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms import add_blank_choice
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import DatePicker
__all__ = (
'CircuitBulkEditForm',

View File

@@ -6,7 +6,8 @@ from dcim.models import Site
from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
from utilities.forms import BootstrapMixin
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
__all__ = (
'CircuitImportForm',

View File

@@ -7,7 +7,8 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.widgets import DatePicker
__all__ = (
'CircuitFilterForm',

View File

@@ -5,9 +5,8 @@ from dcim.models import Site
from ipam.models import ASN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import (
CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
)
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
from utilities.forms.widgets import DatePicker, SelectSpeedWidget
__all__ = (
'CircuitForm',

View File

@@ -64,7 +64,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'

View File

@@ -247,6 +247,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable

View File

@@ -1,17 +1,18 @@
import logging
import os
import re
import subprocess
import tempfile
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote, urlunparse, urlparse
from urllib.parse import urlparse
import boto3
from botocore.config import Config as Boto3Config
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from dulwich import porcelain
from dulwich.config import StackedConfig
from netbox.registry import registry
from .choices import DataSourceTypeChoices
@@ -20,6 +21,7 @@ from .exceptions import SyncError
__all__ = (
'LocalBackend',
'GitBackend',
'S3Backend',
)
logger = logging.getLogger('netbox.data_backends')
@@ -87,37 +89,23 @@ class GitBackend(DataBackend):
def fetch(self):
local_path = tempfile.TemporaryDirectory()
# Add authentication credentials to URL (if specified)
username = self.params.get('username')
password = self.params.get('password')
if username and password:
# Add username & password to URL
parsed = urlparse(self.url)
url = f'{parsed.scheme}://{quote(username)}:{quote(password)}@{parsed.netloc}{parsed.path}'
else:
url = self.url
branch = self.params.get('branch')
config = StackedConfig.default()
# Compile git arguments
args = [settings.GIT_PATH, 'clone', '--depth', '1']
if branch := self.params.get('branch'):
args.extend(['--branch', branch])
args.extend([url, local_path.name])
# Prep environment variables
env_vars = {}
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme)
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
logger.debug(f"Cloning git repo: {' '.join(args)}")
logger.debug(f"Cloning git repo: {self.url}")
try:
subprocess.run(args, check=True, capture_output=True, env=env_vars)
except FileNotFoundError as e:
raise SyncError(
f"Unable to fetch: git executable not found. Check that the git executable exists at the "
f"configured path: {settings.GIT_PATH}"
porcelain.clone(
self.url, local_path.name, depth=1, branch=branch, username=username, password=password,
config=config, quiet=True, errstream=porcelain.NoneStream()
)
except subprocess.CalledProcessError as e:
raise SyncError(f"Fetching remote data failed: {e.stderr}")
except BaseException as e:
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
yield local_path.name

View File

@@ -4,7 +4,9 @@ from django.utils.translation import gettext as _
from core.choices import DataSourceTypeChoices
from core.models import *
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import add_blank_choice, BulkEditNullBooleanSelect, CommentField
from utilities.forms import add_blank_choice
from utilities.forms.fields import CommentField
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
'DataSourceBulkEditForm',

View File

@@ -8,10 +8,9 @@ from core.models import *
from extras.forms.mixins import SavedFiltersMixin
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelFilterSetForm
from utilities.forms import (
APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, DateTimePicker,
DynamicModelMultipleChoiceField, FilterForm,
)
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
__all__ = (
'DataFileFilterForm',

View File

@@ -6,7 +6,9 @@ from core.models import *
from extras.forms.mixins import SyncedDataMixin
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from utilities.forms import CommentField, get_field_value
from utilities.forms import get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.widgets import HTMXSelect
__all__ = (
'DataSourceForm',
@@ -23,13 +25,7 @@ class DataSourceForm(NetBoxModelForm):
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
]
widgets = {
'type': forms.Select(
attrs={
'hx-get': '.',
'hx-include': '#form_fields input',
'hx-target': '#form_fields',
}
),
'type': HTMXSelect(),
'ignore_rules': forms.Textarea(
attrs={
'rows': 5,
@@ -84,12 +80,12 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
fieldsets = (
('File Upload', ('upload_file',)),
('Data Source', ('data_source', 'data_file')),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
)
class Meta:
model = ManagedFile
fields = ('data_source', 'data_file')
fields = ('data_source', 'data_file', 'auto_sync_enabled')
def clean(self):
super().clean()

View File

@@ -24,7 +24,10 @@ def sync_datasource(job, *args, **kwargs):
job.terminate()
except (SyncError, JobTimeoutException) as e:
except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
logging.error(e)
if type(e) in (SyncError, JobTimeoutException):
logging.error(e)
else:
raise e

View File

@@ -63,4 +63,21 @@ class Migration(migrations.Migration):
model_name='datafile',
index=models.Index(fields=['source', 'path'], name='core_datafile_source_path'),
),
migrations.CreateModel(
name='AutoSyncRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.PositiveBigIntegerField()),
('datafile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.datafile')),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
],
),
migrations.AddIndex(
model_name='autosyncrecord',
index=models.Index(fields=['object_type', 'object_id'], name='core_autosy_object__c17bac_idx'),
),
migrations.AddConstraint(
model_name='autosyncrecord',
constraint=models.UniqueConstraint(fields=('object_type', 'object_id'), name='core_autosyncrecord_object'),
),
]

View File

@@ -23,6 +23,7 @@ class Migration(migrations.Migration):
('file_path', models.FilePathField(editable=False)),
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
('auto_sync_enabled', models.BooleanField(default=False)),
],
options={
'ordering': ('file_root', 'file_path'),

View File

@@ -27,6 +27,7 @@ def replicate_jobresults(apps, schema_editor):
)
if len(jobs) == 100:
Job.objects.bulk_create(jobs)
jobs = []
if jobs:
Job.objects.bulk_create(jobs)

View File

@@ -5,7 +5,8 @@ from fnmatch import fnmatchcase
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
@@ -25,6 +26,7 @@ from ..signals import post_sync, pre_sync
from .jobs import Job
__all__ = (
'AutoSyncRecord',
'DataFile',
'DataSource',
)
@@ -95,6 +97,10 @@ class DataSource(JobsMixin, PrimaryModel):
def url_scheme(self):
return urlparse(self.source_url).scheme.lower()
@property
def is_local(self):
return self.type == DataSourceTypeChoices.LOCAL
@property
def ready_for_sync(self):
return self.enabled and self.status not in (
@@ -107,7 +113,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
raise ValidationError({
'url': f"URLs for local sources must start with file:// (or omit the scheme)"
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
})
def enqueue_sync_job(self, request):
@@ -172,7 +178,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Bulk delete deleted files
deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
logger.debug(f"Deleted {updated_count} files")
logger.debug(f"Deleted {deleted_count} files")
# Walk the local replication to find new files
new_paths = self._walk(local_path) - known_paths
@@ -323,3 +329,35 @@ class DataFile(models.Model):
with open(path, 'wb+') as new_file:
new_file.write(self.data)
class AutoSyncRecord(models.Model):
"""
Maps a DataFile to a synced object for efficient automatic updating.
"""
datafile = models.ForeignKey(
to=DataFile,
on_delete=models.CASCADE,
related_name='+'
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
class Meta:
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id'),
name='%(app_label)s_%(class)s_object'
),
)
indexes = (
models.Index(fields=('object_type', 'object_id')),
)

View File

@@ -102,6 +102,7 @@ class Job(models.Model):
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk})
if self.object_type.model == 'scriptmodule':
return reverse(f'extras:script_result', kwargs={'job_pk': self.pk})
return reverse('core:job', args=[self.pk])
def get_status_color(self):
return JobStatusChoices.colors.get(self.status)

View File

@@ -1,4 +1,4 @@
import django.dispatch
from django.dispatch import Signal, receiver
__all__ = (
'post_sync',
@@ -6,5 +6,16 @@ __all__ = (
)
# DataSource signals
pre_sync = django.dispatch.Signal()
post_sync = django.dispatch.Signal()
pre_sync = Signal()
post_sync = Signal()
@receiver(post_sync)
def auto_sync(instance, **kwargs):
"""
Automatically synchronize any DataFiles with AutoSyncRecords after synchronizing a DataSource.
"""
from .models import AutoSyncRecord
for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
autosync.object.sync(save=True)

View File

@@ -22,6 +22,7 @@ urlpatterns = (
# Job results
path('jobs/', views.JobListView.as_view(), name='job_list'),
path('jobs/delete/', views.JobBulkDeleteView.as_view(), name='job_bulk_delete'),
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
)

View File

@@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404, redirect
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from utilities.rqworker import get_queue_for_model, get_workers_for_queue
from utilities.utils import count_related
from utilities.views import register_model_view
from . import filtersets, forms, tables
@@ -127,7 +126,11 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
actions = ('export', 'delete', 'bulk_delete', )
actions = ('export', 'delete', 'bulk_delete')
class JobView(generic.ObjectView):
queryset = Job.objects.all()
class JobDeleteView(generic.ObjectDeleteView):

View File

@@ -1097,7 +1097,8 @@ class CableTerminationSerializer(NetBoxModelSerializer):
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination',
'created', 'last_updated',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@@ -423,9 +423,13 @@ class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBo
configtemplate = device.get_config_template()
if not configtemplate:
return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
context = {**request.data, 'device': device}
return self.render_configtemplate(request, configtemplate, context)
# Compile context data
context_data = device.get_config_context()
context_data.update(request.data)
context_data.update({'device': device})
return self.render_configtemplate(request, configtemplate, context_data)
class VirtualDeviceContextViewSet(NetBoxModelViewSet):

View File

@@ -25,6 +25,7 @@ __all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@@ -1348,11 +1349,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
@@ -1397,14 +1450,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
@@ -1415,17 +1460,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(),
@@ -1443,17 +1477,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
@@ -1483,24 +1506,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 {
@@ -1689,12 +1694,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(
@@ -1752,6 +1759,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()

View File

@@ -4,7 +4,8 @@ from dcim.models import *
from django.utils.translation import gettext as _
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
from utilities.forms import BootstrapMixin, form_from_model
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
from .object_create import ComponentCreateForm
__all__ = (
@@ -103,9 +104,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.)')

View File

@@ -10,10 +10,9 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SelectSpeedWidget,
)
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect, SelectSpeedWidget
__all__ = (
'CableBulkEditForm',

View File

@@ -12,8 +12,9 @@ from extras.models import ConfigTemplate
from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
SlugField,
)
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from utilities.forms.utils import get_field_value
from utilities.forms import get_field_value
__all__ = (
'InterfaceCommonForm',

View File

@@ -1,9 +1,9 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.models import Circuit, CircuitTermination, Provider
from circuits.models import Circuit, CircuitTermination
from dcim.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .model_forms import CableForm

View File

@@ -10,10 +10,9 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
)
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.widgets import APISelectMultiple, SelectSpeedWidget
from wireless.choices import *
__all__ = (

View File

@@ -11,11 +11,12 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK,
SlugField, SelectSpeedWidget
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
NumericArrayField, SlugField,
)
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, SelectSpeedWidget, SelectWithPK
from virtualization.models import Cluster
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm
@@ -1136,13 +1137,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
]
widgets = {
'speed': SelectSpeedWidget(),
'mode': forms.Select(
attrs={
'hx-get': '.',
'hx-include': '#form_fields input',
'hx-target': '#form_fields',
}
),
'mode': HTMXSelect(),
}
labels = {
'mode': '802.1Q Mode',

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from . import model_forms
__all__ = (

View File

@@ -1,6 +1,5 @@
# Generated by Django 4.1.5 on 2023-01-09 07:03
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
@@ -15,4 +14,9 @@ class Migration(migrations.Migration):
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='interfacetemplate',
name='bridge',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0170_configtemplate'),
]
operations = [
migrations.AddField(
model_name='cabletermination',
name='created',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='cabletermination',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 4.1.6 on 2023-03-01 13:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0170_configtemplate'),
]
operations = [
migrations.AddField(
model_name='interfacetemplate',
name='bridge',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'),
),
]

View File

@@ -13,7 +13,8 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import PrimaryModel
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
@@ -152,8 +153,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 +186,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)
@@ -220,7 +223,7 @@ class Cable(PrimaryModel):
return LinkStatusChoices.colors.get(self.status)
class CableTermination(models.Model):
class CableTermination(ChangeLoggedModel):
"""
A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
"""
@@ -357,6 +360,11 @@ class CableTermination(models.Model):
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.termination
return objectchange
class CablePath(models.Model):
"""

View File

@@ -119,6 +119,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
),
)
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)
if self.device_type is not None:
@@ -130,6 +136,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
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."
})
# A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type:
raise ValidationError(

View File

@@ -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,14 @@ class ComponentModel(NetBoxModel):
objectchange.related_object = self.device
return objectchange
def clean(self):
super().clean()
if 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
@@ -797,8 +811,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:
@@ -806,8 +818,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
@@ -818,6 +828,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)

View File

@@ -128,6 +128,10 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True
)
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = (
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
@@ -707,8 +711,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:
@@ -824,8 +826,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
@@ -838,8 +842,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):
@@ -853,6 +858,10 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.platform:
self.platform = self.device_type.default_platform
# Inherit location from Rack if not set
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

View File

@@ -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

View File

@@ -12,12 +12,12 @@ LINKTERMINATION = """
CABLE_LENGTH = """
{% load helpers %}
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
"""
WEIGHT = """
{% load helpers %}
{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
{% if value %}{{ value|floatformat:"-2" }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """

View File

@@ -6,6 +6,7 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
@@ -578,6 +579,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable
@@ -867,6 +869,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable
@@ -1728,6 +1731,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable
@@ -1785,6 +1789,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable
@@ -2008,12 +2013,28 @@ class DeviceRenderConfigView(generic.ObjectView):
weight=2100
)
def get(self, request, **kwargs):
instance = self.get_object(**kwargs)
context = self.get_extra_context(request, instance)
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
return render(request, self.get_template_name(), {
'object': instance,
'tab': self.tab,
**context,
})
def get_extra_context(self, request, instance):
# Compile context data
context_data = {
'device': instance,
}
context_data.update(**instance.get_config_context())
context_data = instance.get_config_context()
context_data.update({'device': instance})
# Render the config template
rendered_config = None
@@ -2855,6 +2876,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'
@@ -2911,6 +2933,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable

View File

@@ -1,3 +1,4 @@
from jinja2.exceptions import TemplateError
from rest_framework.response import Response
from .nested_serializers import NestedConfigTemplateSerializer
@@ -32,7 +33,12 @@ class ConfigContextQuerySetMixin:
class ConfigTemplateRenderMixin:
def render_configtemplate(self, request, configtemplate, context):
output = configtemplate.render(context=context)
try:
output = configtemplate.render(context=context)
except TemplateError as e:
return Response({
'detail': f"An error occurred while rendering the template (line {e.lineno}): {e}"
}, status=500)
# If the client has requested "text/plain", return the raw content.
if request.accepted_renderer.format == 'txt':

View File

@@ -104,6 +104,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'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
@extend_schema_field(OpenApiTypes.STR)
def get_data_type(self, obj):
types = CustomFieldTypeChoices
@@ -437,6 +443,16 @@ class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
return value
#
# Scripts
@@ -472,6 +488,16 @@ class ScriptInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
return value
def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
return value
class ScriptLogMessageSerializer(serializers.Serializer):
status = serializers.SerializerMethodField(read_only=True)

View File

@@ -244,8 +244,12 @@ class ReportViewSet(ViewSet):
raise RQWorkerNotRunningException()
# Retrieve and run the Report. This will create a new Job.
module, report = self._get_report(pk)
input_serializer = serializers.ReportInputSerializer(data=request.data)
module, report_cls = self._get_report(pk)
report = report_cls()
input_serializer = serializers.ReportInputSerializer(
data=request.data,
context={'report': report}
)
if input_serializer.is_valid():
report.result = Job.enqueue(
@@ -329,7 +333,10 @@ class ScriptViewSet(ViewSet):
raise PermissionDenied("This user does not have permission to run scripts.")
module, script = self._get_script(pk)
input_serializer = serializers.ScriptInputSerializer(data=request.data)
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}
)
# Check that at least one RQ worker is running
if not Worker.count(get_connection('default')):

View File

@@ -1,5 +1,6 @@
import uuid
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from netbox.registry import registry
@@ -54,8 +55,11 @@ def get_dashboard(user):
def get_default_dashboard():
from extras.models import Dashboard
dashboard = Dashboard()
for widget in DEFAULT_DASHBOARD:
default_config = settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
for widget in default_config:
id = str(uuid.uuid4())
dashboard.layout.append({
'id': id,

View File

@@ -1,6 +1,7 @@
import uuid
from functools import cached_property
from hashlib import sha256
from urllib.parse import urlencode
import feedparser
from django import forms
@@ -11,6 +12,7 @@ from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.translation import gettext as _
from extras.utils import FeatureQuery
from utilities.forms import BootstrapMixin
from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown
@@ -23,24 +25,56 @@ __all__ = (
'ObjectCountsWidget',
'ObjectListWidget',
'RSSFeedWidget',
'WidgetConfigForm',
)
def get_content_type_labels():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.order_by('app_label', 'model')
for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query()
).order_by('app_label', 'model')
]
def get_models_from_content_types(content_types):
"""
Return a list of models corresponding to the given content types, identified by natural key.
"""
models = []
for content_type_id in content_types:
app_label, model_name = content_type_id.split('.')
content_type = ContentType.objects.get_by_natural_key(app_label, model_name)
models.append(content_type.model_class())
return models
class WidgetConfigForm(BootstrapMixin, forms.Form):
pass
class DashboardWidget:
"""
Base class for custom dashboard widgets.
Attributes:
description: A brief, user-friendly description of the widget's function
default_title: The string to show for the widget's title when none has been specified.
default_config: Default configuration parameters, as a dictionary mapping
width: The widget's default width (1 to 12)
height: The widget's default height; the number of rows it consumes
"""
description = None
default_title = None
default_config = {}
description = None
width = 4
height = 3
class ConfigForm(forms.Form):
class ConfigForm(WidgetConfigForm):
"""
The widget's configuration form.
"""
pass
def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
@@ -58,12 +92,18 @@ class DashboardWidget:
return self.title or self.__class__.__name__
def set_layout(self, grid_item):
self.width = grid_item['w']
self.height = grid_item['h']
self.width = grid_item.get('w', 1)
self.height = grid_item.get('h', 1)
self.x = grid_item.get('x')
self.y = grid_item.get('y')
def render(self, request):
"""
This method is called to render the widget's content.
Params:
request: The current request
"""
raise NotImplementedError(f"{self.__class__} must define a render() method.")
@property
@@ -84,7 +124,7 @@ class NoteWidget(DashboardWidget):
default_title = _('Note')
description = _('Display some arbitrary custom content. Markdown is supported.')
class ConfigForm(BootstrapMixin, forms.Form):
class ConfigForm(WidgetConfigForm):
content = forms.CharField(
widget=forms.Textarea()
)
@@ -99,19 +139,40 @@ class ObjectCountsWidget(DashboardWidget):
description = _('Display a set of NetBox models and the number of objects created for each type.')
template_name = 'extras/dashboard/widgets/objectcounts.html'
class ConfigForm(BootstrapMixin, forms.Form):
class ConfigForm(WidgetConfigForm):
models = forms.MultipleChoiceField(
choices=get_content_type_labels
)
filters = forms.JSONField(
required=False,
label='Object filters',
help_text=_("Only objects matching the specified filters will be counted")
)
def clean_filters(self):
if data := self.cleaned_data['filters']:
try:
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
for model in get_models_from_content_types(self.cleaned_data.get('models')):
try:
# Validate the filters by creating a QuerySet
model.objects.filter(**data).none()
except Exception:
model_name = model._meta.verbose_name_plural
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data
def render(self, request):
counts = []
for content_type_id in self.config['models']:
app_label, model_name = content_type_id.split('.')
model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()
for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission):
object_count = model.objects.restrict(request.user, 'view').count
qs = model.objects.restrict(request.user, 'view')
if filters := self.config.get('filters'):
qs = qs.filter(**filters)
object_count = qs.count
counts.append((model, object_count))
else:
counts.append((model, None))
@@ -129,7 +190,7 @@ class ObjectListWidget(DashboardWidget):
width = 12
height = 4
class ConfigForm(BootstrapMixin, forms.Form):
class ConfigForm(WidgetConfigForm):
model = forms.ChoiceField(
choices=get_content_type_labels
)
@@ -139,6 +200,18 @@ class ObjectListWidget(DashboardWidget):
max_value=100,
help_text=_('The default number of objects to display')
)
url_params = forms.JSONField(
required=False,
label='URL parameters'
)
def clean_url_params(self):
if data := self.cleaned_data['url_params']:
try:
urlencode(data)
except (TypeError, ValueError):
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
return data
def render(self, request):
app_label, model_name = self.config['model'].split('.')
@@ -154,6 +227,11 @@ class ObjectListWidget(DashboardWidget):
htmx_url = reverse(viewname)
except NoReverseMatch:
htmx_url = None
if parameters := self.config.get('url_params'):
try:
htmx_url = f'{htmx_url}?{urlencode(parameters)}'
except ValueError:
pass
return render_to_string(self.template_name, {
'viewname': viewname,
'has_permission': has_permission,
@@ -174,7 +252,7 @@ class RSSFeedWidget(DashboardWidget):
width = 6
height = 4
class ConfigForm(BootstrapMixin, forms.Form):
class ConfigForm(WidgetConfigForm):
feed_url = forms.URLField(
label=_('Feed URL')
)

View File

@@ -3,9 +3,9 @@ from django.utils.translation import gettext as _
from extras.choices import *
from extras.models import *
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
)
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
'ConfigContextBulkEditForm',

View File

@@ -7,7 +7,8 @@ from django.utils.translation import gettext as _
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
__all__ = (
'ConfigTemplateImportForm',

View File

@@ -10,10 +10,9 @@ from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeMultipleChoiceField, DateTimePicker,
DynamicModelMultipleChoiceField, FilterForm, TagFilterField,
)
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin

View File

@@ -1,6 +1,7 @@
import json
from django import forms
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
@@ -11,9 +12,10 @@ from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
SlugField,
)
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -39,7 +41,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):
)
}
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(
@@ -103,7 +112,7 @@ class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = (
('Export Template', ('name', 'content_types', 'description', 'template_code')),
('Data Source', ('data_source', 'data_file')),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
@@ -262,7 +271,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = (
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
('Data Source', ('data_source', 'data_file')),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
('Assignment', (
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@@ -274,7 +283,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data_source', 'data_file',
'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
)
def __init__(self, *args, initial=None, **kwargs):
@@ -313,7 +322,7 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
fieldsets = (
('Config Template', ('name', 'description', 'environment_params', 'tags')),
('Content', ('template_code',)),
('Data Source', ('data_source', 'data_file')),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
)
class Meta:

View File

@@ -1,8 +1,8 @@
from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
from utilities.forms import BootstrapMixin
from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
from utilities.utils import local_now
__all__ = (
@@ -25,20 +25,25 @@ class ReportForm(BootstrapMixin, forms.Form):
help_text=_("Interval at which this report is re-run (in minutes)")
)
def clean(self):
scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
# 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):
def __init__(self, *args, scheduling_enabled=True, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
# Remove scheduling fields if scheduling is disabled
if not scheduling_enabled:
self.fields.pop('schedule_at')
self.fields.pop('interval')
def clean(self):
scheduled_time = self.cleaned_data.get('schedule_at')
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
# When interval is used without schedule at, schedule for the current time
if self.cleaned_data.get('interval') and not scheduled_time:
self.cleaned_data['schedule_at'] = local_now()
return self.cleaned_data

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
from utilities.forms import BootstrapMixin
from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
from utilities.utils import local_now
__all__ = (
@@ -30,28 +31,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
help_text=_("Interval at which this script is re-run (in minutes)")
)
def __init__(self, *args, **kwargs):
def __init__(self, *args, scheduling_enabled=True, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
# Move _commit and _schedule_at to the end of the form
schedule_at = self.fields.pop('_schedule_at')
interval = self.fields.pop('_interval')
commit = self.fields.pop('_commit')
self.fields['_schedule_at'] = schedule_at
self.fields['_interval'] = interval
self.fields['_commit'] = commit
# Remove scheduling fields if scheduling is disabled
if not scheduling_enabled:
self.fields.pop('_schedule_at')
self.fields.pop('_interval')
def clean(self):
scheduled_time = self.cleaned_data['_schedule_at']
scheduled_time = self.cleaned_data.get('_schedule_at')
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
# When interval is used without schedule at, raise an exception
if self.cleaned_data['_interval'] and not scheduled_time:
# When interval is used without schedule at, schedule for the current time
if self.cleaned_data.get('_interval') and not scheduled_time:
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data

View File

@@ -25,7 +25,7 @@ class ConfigContextType(ObjectType):
filterset_class = filtersets.ConfigContextFilterSet
class ConfigTemplateType(ObjectType):
class ConfigTemplateType(TagsMixin, ObjectType):
class Meta:
model = models.ConfigTemplate

View File

@@ -26,6 +26,11 @@ class Migration(migrations.Migration):
name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
),
migrations.AddField(
model_name='configcontext',
name='auto_sync_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='configcontext',
name='data_synced',
@@ -47,6 +52,11 @@ class Migration(migrations.Migration):
name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
),
migrations.AddField(
model_name='exporttemplate',
name='auto_sync_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='exporttemplate',
name='data_synced',

View File

@@ -25,6 +25,7 @@ class Migration(migrations.Migration):
('environment_params', models.JSONField(blank=True, default=dict, null=True)),
('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
('auto_sync_enabled', models.BooleanField(default=False)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={

View File

@@ -245,7 +245,10 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
# Initialize the Jinja2 environment and instantiate the Template
environment = self._get_environment()
template = environment.from_string(self.template_code)
if self.data_file:
template = environment.get_template(self.data_file.path)
else:
template = environment.from_string(self.template_code)
output = template.render(**context)
# Replace CRLF-style line terminators

View File

@@ -221,7 +221,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
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)

View File

@@ -306,7 +306,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
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,
@@ -368,7 +368,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
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)

View File

@@ -83,6 +83,7 @@ class Report(object):
}
"""
description = None
scheduling_enabled = True
job_timeout = None
def __init__(self):

View File

@@ -21,7 +21,8 @@ from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@@ -296,6 +297,12 @@ class BaseScript:
def full_name(self):
return f'{self.module}.{self.class_name}'
@classmethod
def root_module(cls):
return cls.__module__.split(".")[0]
# Author-defined attributes
@classproperty
def name(self):
return getattr(self.Meta, 'name', self.__name__)
@@ -304,14 +311,26 @@ class BaseScript:
def description(self):
return getattr(self.Meta, 'description', '')
@classmethod
def root_module(cls):
return cls.__module__.split(".")[0]
@classproperty
def field_order(self):
return getattr(self.Meta, 'field_order', None)
@classproperty
def fieldsets(self):
return getattr(self.Meta, 'fieldsets', None)
@classproperty
def commit_default(self):
return getattr(self.Meta, 'commit_default', True)
@classproperty
def job_timeout(self):
return getattr(self.Meta, 'job_timeout', None)
@classproperty
def scheduling_enabled(self):
return getattr(self.Meta, 'scheduling_enabled', True)
@classmethod
def _get_vars(cls):
vars = {}
@@ -327,11 +346,10 @@ class BaseScript:
vars[name] = attr
# Order variables according to field_order
field_order = getattr(cls.Meta, 'field_order', None)
if not field_order:
if not cls.field_order:
return vars
ordered_vars = {
field: vars.pop(field) for field in field_order if field in vars
field: vars.pop(field) for field in cls.field_order if field in vars
}
ordered_vars.update(vars)
@@ -340,6 +358,23 @@ class BaseScript:
def run(self, data, commit):
raise NotImplementedError("The script must define a run() method.")
# Form rendering
def get_fieldsets(self):
fieldsets = []
if self.fieldsets:
fieldsets.extend(self.fieldsets)
else:
fields = (name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields))
# Append the default fieldset if defined in the Meta class
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
fieldsets.append(('Script Execution Parameters', exec_parameters))
return fieldsets
def as_form(self, data=None, files=None, initial=None):
"""
Return a Django form suitable for populating the context data required to run this Script.
@@ -353,19 +388,7 @@ class BaseScript:
form = FormClass(data, files, initial=initial)
# 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
form.fields['_commit'].initial = self.commit_default
return form

View File

@@ -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 core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
@@ -17,8 +14,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):
@@ -108,6 +103,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):
@@ -518,6 +518,46 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(rendered_context['bar'], 456)
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConfigTemplate
brief_fields = ['display', 'id', 'name', 'url']
create_data = [
{
'name': 'Config Template 4',
'template_code': 'Foo: {{ foo }}',
},
{
'name': 'Config Template 5',
'template_code': 'Bar: {{ bar }}',
},
{
'name': 'Config Template 6',
'template_code': 'Baz: {{ baz }}',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
config_templates = (
ConfigTemplate(
name='Config Template 1',
template_code='Foo: {{ foo }}'
),
ConfigTemplate(
name='Config Template 2',
template_code='Bar: {{ bar }}'
),
ConfigTemplate(
name='Config Template 3',
template_code='Baz: {{ baz }}'
),
)
ConfigTemplate.objects.bulk_create(config_templates)
class ReportTest(APITestCase):
class TestReport(Report):
@@ -547,16 +587,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):
@@ -603,27 +633,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):

View File

@@ -790,6 +790,28 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
queryset = ConfigTemplate.objects.all()
filterset = ConfigTemplateFilterSet
@classmethod
def setUpTestData(cls):
config_templates = (
ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
ConfigTemplate(name='Config Template 2', template_code='TESTING', description='foobar2'),
ConfigTemplate(name='Config Template 3', template_code='TESTING'),
)
ConfigTemplate.objects.bulk_create(config_templates)
def test_name(self):
params = {'name': ['Config Template 1', 'Config Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all()
filterset = TagFilterSet

View File

@@ -360,6 +360,45 @@ class ConfigContextTestCase(
}
class ConfigTemplateTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = ConfigTemplate
@classmethod
def setUpTestData(cls):
TEMPLATE_CODE = """Foo: {{ foo }}"""
config_templates = (
ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
ConfigTemplate(name='Config Template 2', template_code=TEMPLATE_CODE),
ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE),
)
ConfigTemplate.objects.bulk_create(config_templates)
cls.form_data = {
'name': 'Config Template X',
'description': 'Config template',
'template_code': TEMPLATE_CODE,
}
cls.csv_update_data = (
"id,name",
f"{config_templates[0].pk},Config Template 7",
f"{config_templates[1].pk},Config Template 8",
f"{config_templates[2].pk},Config Template 9",
)
cls.bulk_edit_data = {
'description': 'New description',
}
# TODO: Convert to StandardTestCases.Views
class ObjectChangeTestCase(TestCase):
user_permissions = (

View File

@@ -422,6 +422,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
filterset = filtersets.ConfigContextFilterSet
table = tables.ConfigContextTable
@@ -875,7 +876,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return render(request, 'extras/report.html', {
'module': module,
'report': report,
'form': ReportForm(),
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
})
def post(self, request, module, name):
@@ -884,7 +885,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
report = module.reports[name]()
form = ReportForm(request.POST)
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
if form.is_valid():
@@ -944,8 +945,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
name=report.name
)
jobs_table = JobTable(
@@ -1118,8 +1118,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
name=script.class_name
)
jobs_table = JobTable(

View File

@@ -2,7 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.routers import APIRootView
@@ -15,7 +15,7 @@ from ipam.models import *
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.utils import count_related
from . import serializers
from ipam.models import L2VPN, L2VPNTermination

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, ExpandableIPAddressField
from utilities.forms import BootstrapMixin
from utilities.forms.fields import ExpandableIPAddressField
__all__ = (
'IPAddressBulkCreateForm',

View File

@@ -8,10 +8,11 @@ from ipam.models import *
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
NumericArrayField,
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
)
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
'AggregateBulkEditForm',

View File

@@ -9,7 +9,7 @@ from ipam.constants import *
from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
from virtualization.models import VirtualMachine, VMInterface
__all__ = (
@@ -456,7 +456,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):

View File

@@ -8,9 +8,9 @@ from ipam.constants import *
from ipam.models import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import (
ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from virtualization.models import VirtualMachine

View File

@@ -11,10 +11,12 @@ from ipam.models import *
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.exceptions import PermissionsViolation
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, NumericArrayField, SlugField,
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
SlugField,
)
from utilities.forms.widgets import DatePicker
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
__all__ = (

View File

@@ -120,9 +120,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({

View File

@@ -952,7 +952,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)

View File

@@ -465,6 +465,7 @@ class RoleBulkEditView(generic.BulkEditView):
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
filterset = filtersets.RoleFilterSet
table = tables.RoleTable

View File

@@ -23,8 +23,7 @@ class SyncedDataMixin:
obj = get_object_or_404(self.queryset, pk=pk)
if obj.data_file:
obj.sync()
obj.save()
obj.sync(save=True)
serializer = self.serializer_class(obj, context={'request': request})
return Response(serializer.data)

View File

@@ -14,7 +14,6 @@ __all__ = (
)
@extend_schema_field(OpenApiTypes.STR)
class ChoiceField(serializers.Field):
"""
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Accepts a single value on write.

View File

@@ -5,3 +5,14 @@ NESTED_SERIALIZER_PREFIX = 'Nested'
RQ_QUEUE_DEFAULT = 'default'
RQ_QUEUE_HIGH = 'high'
RQ_QUEUE_LOW = 'low'
# Keys for PostgreSQL advisory locks. These are arbitrary bigints used by the advisory_lock
# context manager. When a lock is acquired, one of these keys will be used to identify said lock.
# When adding a new key, pick something arbitrary and unique so that it is easily searchable in
# query logs.
ADVISORY_LOCK_KEYS = {
'available-prefixes': 100100,
'available-ips': 100200,
'available-vlans': 100300,
'available-asns': 100400,
}

View File

@@ -14,7 +14,7 @@ from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP
)
from utilities.forms import MACAddressField
from utilities.forms.fields import MACAddressField
from utilities import filters
__all__ = (

View File

@@ -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):
"""

View File

@@ -3,6 +3,7 @@ from collections import defaultdict
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from django.db.models.signals import class_prepared
@@ -382,6 +383,10 @@ class SyncedDataMixin(models.Model):
editable=False,
help_text=_("Path to remote file (relative to data source root)")
)
auto_sync_enabled = models.BooleanField(
default=False,
help_text=_("Enable automatic synchronization of data when the data file is updated")
)
data_synced = models.DateTimeField(
blank=True,
null=True,
@@ -404,10 +409,33 @@ class SyncedDataMixin(models.Model):
else:
self.data_source = None
self.data_path = ''
self.auto_sync_enabled = False
self.data_synced = None
super().clean()
def save(self, *args, **kwargs):
from core.models import AutoSyncRecord
ret = super().save(*args, **kwargs)
# Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled:
AutoSyncRecord.objects.get_or_create(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
)
else:
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
).delete()
return ret
def resolve_data_file(self):
"""
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
@@ -421,13 +449,17 @@ class SyncedDataMixin(models.Model):
except DataFile.DoesNotExist:
pass
def sync(self):
def sync(self, save=False):
"""
Synchronize the object from it's assigned DataFile (if any). This wraps sync_data() and updates
the synced_data timestamp.
:param save: If true, save() will be called after data has been synchronized
"""
self.sync_data()
self.data_synced = timezone.now()
if save:
self.save()
def sync_data(self):
"""

View File

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.5-beta1'
VERSION = '3.5-beta2'
# Hostname
HOSTNAME = platform.node()
@@ -86,6 +86,7 @@ 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')
DEBUG = getattr(configuration, 'DEBUG', False)
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
EMAIL = getattr(configuration, 'EMAIL', {})
@@ -345,6 +346,7 @@ INSTALLED_APPS = [
'wireless',
'django_rq', # Must come after extras to allow overriding management commands
'drf_spectacular',
'drf_spectacular_sidecar',
]
# Middleware
@@ -584,6 +586,9 @@ SPECTACULAR_SETTINGS = {
"LICENSE": {"name": "Apache v2 License"},
"VERSION": VERSION,
'COMPONENT_SPLIT_REQUEST': True,
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
}
#

View File

@@ -503,6 +503,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']):
@@ -511,25 +526,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])
@@ -547,6 +547,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'])

View File

@@ -205,8 +205,7 @@ class ObjectSyncDataView(View):
messages.error(request, f"Unable to synchronize data: No data file set.")
return redirect(obj.get_absolute_url())
obj.sync()
obj.save()
obj.sync(save=True)
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
return redirect(obj.get_absolute_url())
@@ -227,8 +226,7 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
with transaction.atomic():
for obj in selected_objects:
obj.sync()
obj.save()
obj.sync(save=True)
model_name = self.queryset.model._meta.verbose_name_plural
messages.success(request, f"Synced {len(selected_objects)} {model_name}")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More