mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-02 07:27:14 +02:00
Compare commits
80 Commits
v3.5-beta1
...
v3.5-beta2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4452f57f90 | ||
|
|
b167153186 | ||
|
|
197c6a1cbf | ||
|
|
014a5d10d1 | ||
|
|
86d185fe05 | ||
|
|
9ef1fb1e3a | ||
|
|
7ecf3be33c | ||
|
|
a0893c2e8b | ||
|
|
8b040ff930 | ||
|
|
d470848b29 | ||
|
|
59a6b3e71b | ||
|
|
c1c98f9883 | ||
|
|
dd8112c30e | ||
|
|
3c91331e16 | ||
|
|
eef38257b9 | ||
|
|
bb9a125934 | ||
|
|
8de252e34e | ||
|
|
9e305c6181 | ||
|
|
97ed6439ce | ||
|
|
15a615d3a3 | ||
|
|
3528aaee2c | ||
|
|
46d7bf02ac | ||
|
|
6820796c10 | ||
|
|
4a331b560f | ||
|
|
4c9cf9032c | ||
|
|
ada01b39cc | ||
|
|
b41f8755df | ||
|
|
2c07762b7a | ||
|
|
f68a63255b | ||
|
|
278f2b173a | ||
|
|
00714b23a2 | ||
|
|
768d6f624e | ||
|
|
1146aaff89 | ||
|
|
5a4feb7099 | ||
|
|
589d51e028 | ||
|
|
a6fd0ab09a | ||
|
|
08017c51f6 | ||
|
|
9f71cf79e6 | ||
|
|
c26fe266cc | ||
|
|
085cfc58f4 | ||
|
|
63a0ec7a79 | ||
|
|
ccfdc216a5 | ||
|
|
2bf9acfb19 | ||
|
|
74d8baea30 | ||
|
|
f8d40ae824 | ||
|
|
41c92483a0 | ||
|
|
6d6299f0cb | ||
|
|
f44a2ba0ee | ||
|
|
29fbe6e4ee | ||
|
|
94c2a2e56c | ||
|
|
0a2ae90411 | ||
|
|
1b5f926e17 | ||
|
|
13cbb33c98 | ||
|
|
b032742418 | ||
|
|
8a684adf66 | ||
|
|
bca00cd97a | ||
|
|
2883fa14de | ||
|
|
56d2a9aa11 | ||
|
|
53abcc0f5c | ||
|
|
0676ed45c7 | ||
|
|
872b70c2b5 | ||
|
|
2805633b16 | ||
|
|
f245f07fd9 | ||
|
|
e966d1df47 | ||
|
|
e4b2d87ce6 | ||
|
|
b3a347e6fb | ||
|
|
e7d1a43541 | ||
|
|
5ff9483d13 | ||
|
|
ac07b33602 | ||
|
|
8d6c591535 | ||
|
|
6a85c5b3ce | ||
|
|
bd38b50e5e | ||
|
|
eb77c0e920 | ||
|
|
198c004c1d | ||
|
|
730eb2e83b | ||
|
|
cdad50e051 | ||
|
|
3264636b7a | ||
|
|
fbc23424a6 | ||
|
|
6f08c4a4be | ||
|
|
0ac8419005 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.7
|
||||
placeholder: v3.5-beta2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.4.7
|
||||
placeholder: v3.5-beta2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
[](https://netboxlabs.com)
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
[](https://ns1.com)
|
||||
<br />
|
||||
[](https://sentry.io)
|
||||
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
!!! 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:
|
||||
|
||||
BIN
docs/media/admin_ui_grant_permission.png
Normal file
BIN
docs/media/admin_ui_grant_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
52
docs/plugins/development/dashboard-widgets.md
Normal file
52
docs/plugins/development/dashboard-widgets.md
Normal 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')
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -247,6 +247,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=count_related(Circuit, 'type')
|
||||
)
|
||||
filterset = filtersets.CircuitTypeFilterSet
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.)')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ class ConfigContextType(ObjectType):
|
||||
filterset_class = filtersets.ConfigContextFilterSet
|
||||
|
||||
|
||||
class ConfigTemplateType(ObjectType):
|
||||
class ConfigTemplateType(TagsMixin, ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ConfigTemplate
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -83,6 +83,7 @@ class Report(object):
|
||||
}
|
||||
"""
|
||||
description = None
|
||||
scheduling_enabled = True
|
||||
job_timeout = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -465,6 +465,7 @@ class RoleBulkEditView(generic.BulkEditView):
|
||||
|
||||
class RoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
filterset = filtersets.RoleFilterSet
|
||||
table = tables.RoleTable
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
#
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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}")
|
||||
|
||||
12
netbox/project-static/dist/netbox.js
vendored
12
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
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
Reference in New Issue
Block a user