mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-23 02:18:05 +01:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d1b51304 | ||
|
|
4ae1a1ffe9 | ||
|
|
8107d72961 | ||
|
|
63239d7d9f | ||
|
|
5f3e147634 | ||
|
|
bfd023c6a9 | ||
|
|
f4ac23d868 | ||
|
|
8b62e40874 | ||
|
|
dbcd89c8ed | ||
|
|
00d9a865c0 | ||
|
|
ab3fd0049b | ||
|
|
3e6249387a | ||
|
|
85fd232614 | ||
|
|
dda0b0bbd1 | ||
|
|
3542057839 | ||
|
|
cb72b921ae | ||
|
|
582ede8ed3 | ||
|
|
32e219c70a | ||
|
|
7a5e8a80ea | ||
|
|
9d28af42b2 | ||
|
|
81292df048 | ||
|
|
207c91ef6b | ||
|
|
cd9244fd4f | ||
|
|
973bd0ed75 | ||
|
|
1eebb98b56 | ||
|
|
d2a8e52585 | ||
|
|
b077c664e3 | ||
|
|
6f35a2ac2b | ||
|
|
9559349541 | ||
|
|
6abad9c20c | ||
|
|
c8aac13cee | ||
|
|
49971dd7db | ||
|
|
b2360b62b5 | ||
|
|
a597ad849e | ||
|
|
83da49cfa3 | ||
|
|
5353f83710 | ||
|
|
763d65bed9 | ||
|
|
fbe64cb9a4 | ||
|
|
d85cf9ee0d | ||
|
|
eb3d423077 | ||
|
|
56b6b1b9d8 | ||
|
|
e820c145f3 | ||
|
|
5788b6cb28 | ||
|
|
83dc92ed2d | ||
|
|
c4640534f9 | ||
|
|
e68b83907b | ||
|
|
2682f03a6b | ||
|
|
2304df84d5 | ||
|
|
5530556626 | ||
|
|
e4d240ace2 | ||
|
|
58f22eec37 | ||
|
|
7e1b3d0b54 | ||
|
|
3acf3b51ee | ||
|
|
8f87c72eaa | ||
|
|
18b43408ec | ||
|
|
b10fb67ce9 | ||
|
|
c27cb6f153 | ||
|
|
81f0a40505 | ||
|
|
4242546270 | ||
|
|
87109f5539 | ||
|
|
8ab9afb8db | ||
|
|
7be003f5a0 | ||
|
|
291e0665d0 | ||
|
|
8e48e939aa | ||
|
|
fdad59c8cc | ||
|
|
24d02cb381 | ||
|
|
602754439a | ||
|
|
e18e6cf756 | ||
|
|
0dde0b506e | ||
|
|
26a856f57c | ||
|
|
e095ec6860 | ||
|
|
05c69f84e6 | ||
|
|
05d3224c33 | ||
|
|
4ad74587e5 | ||
|
|
153341c1b7 | ||
|
|
f5aa34bb37 | ||
|
|
a3c4984623 | ||
|
|
67165a9f91 | ||
|
|
4d924a9041 | ||
|
|
a094719d23 | ||
|
|
418389c577 | ||
|
|
f1bf4c8758 | ||
|
|
0bfb9777be | ||
|
|
360f3bc01b | ||
|
|
8a91252d51 | ||
|
|
eb3adc050d | ||
|
|
103c08c2d2 | ||
|
|
806ff646e2 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -26,7 +26,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.0.3
|
||||
placeholder: v4.0.6
|
||||
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: v4.0.3
|
||||
placeholder: v4.0.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
4
.github/workflows/auto-assign-issue.yml
vendored
4
.github/workflows/auto-assign-issue.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: pozil/auto-assign-issue@v1
|
||||
- uses: pozil/auto-assign-issue@v2
|
||||
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
||||
with:
|
||||
# Weighted assignments
|
||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
|
||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
|
||||
numOfAssignee: 1
|
||||
abortIfPreviousAssignees: true
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -1,7 +1,20 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'contrib/**'
|
||||
- 'docs/**'
|
||||
- 'netbox/translations/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'contrib/**'
|
||||
- 'docs/**'
|
||||
- 'netbox/translations/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,12 +47,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -47,7 +60,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
necessary.
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 30
|
||||
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
||||
exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
|
||||
stale-issue-label: 'pending closure'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
|
||||
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Update translation strings
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
LOCALE: "en"
|
||||
|
||||
jobs:
|
||||
makemessages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install -y gettext
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run makemessages
|
||||
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
add: 'netbox/translations/'
|
||||
default_author: github_actions
|
||||
message: 'Update source translation strings'
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,9 +21,11 @@ local_settings.py
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
gunicorn.py
|
||||
uwsgi.ini
|
||||
netbox.log
|
||||
netbox.pid
|
||||
.DS_Store
|
||||
.idea
|
||||
.coverage
|
||||
.vscode
|
||||
.python-version
|
||||
|
||||
@@ -17,7 +17,6 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
|
||||
<a href="#why-netbox">Why NetBox?</a> |
|
||||
<a href="#getting-started">Getting Started</a> |
|
||||
<a href="#get-involved">Get Involved</a> |
|
||||
<a href="#project-stats">Project Stats</a> |
|
||||
<a href="#screenshots">Screenshots</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
|
||||
|
||||
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
||||
|
||||
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||
|
||||
### Bug Bounties
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ django-cors-headers
|
||||
|
||||
# Runtime UI tool for debugging Django
|
||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||
django-debug-toolbar
|
||||
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
|
||||
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
|
||||
django-debug-toolbar==4.3.0
|
||||
|
||||
# Library for writing reusable URL query filters
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
@@ -108,7 +110,7 @@ Pillow
|
||||
|
||||
# PostgreSQL database adapter for Python
|
||||
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
|
||||
psycopg[binary,pool]
|
||||
psycopg[c,pool]
|
||||
|
||||
# YAML rendering library
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"100base-tx",
|
||||
"100base-t1",
|
||||
"1000base-t",
|
||||
"1000base-tx",
|
||||
"2.5gbase-t",
|
||||
"5gbase-t",
|
||||
"10gbase-t",
|
||||
|
||||
95605
contrib/openapi2.json
95605
contrib/openapi2.json
File diff suppressed because it is too large
Load Diff
69695
contrib/openapi2.yaml
69695
contrib/openapi2.yaml
File diff suppressed because it is too large
Load Diff
@@ -65,12 +65,6 @@ class AnotherCustomScript(Script):
|
||||
script_order = (MyCustomScript, AnotherCustomScript)
|
||||
```
|
||||
|
||||
## Module Attributes
|
||||
|
||||
### `name`
|
||||
|
||||
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the module's file name will be used.
|
||||
|
||||
## Script Attributes
|
||||
|
||||
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
||||
@@ -144,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
|
||||
|
||||
The Script object provides a set of convenient functions for recording messages at different severity levels:
|
||||
|
||||
* `log_debug(message, object=None)`
|
||||
* `log_success(message, object=None)`
|
||||
* `log_info(message, object=None)`
|
||||
* `log_warning(message, object=None)`
|
||||
* `log_failure(message, object=None)`
|
||||
* `log_debug(message=None, obj=None)`
|
||||
* `log_success(message=None, obj=None)`
|
||||
* `log_info(message=None, obj=None)`
|
||||
* `log_warning(message=None, obj=None)`
|
||||
* `log_failure(message=None, obj=None)`
|
||||
|
||||
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
|
||||
|
||||
@@ -158,6 +152,8 @@ A script can define one or more test methods to report on certain conditions. Al
|
||||
|
||||
These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
|
||||
|
||||
Calling any of these logging methods without a message will increment the relevant counter, but will not generate an output line in the script's log.
|
||||
|
||||
!!! info
|
||||
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.
|
||||
|
||||
|
||||
@@ -86,15 +86,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
|
||||
|
||||
### Update & Compile Translations
|
||||
|
||||
Log into [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) to download the updated string maps. Download the resource (portable object, or `.po`) file for each language and save them to `netbox/translations/$lang/LC_MESSAGES/django.po`, overwriting the current files. (Be sure to click the **Download for use** link.)
|
||||
|
||||

|
||||
|
||||
Once the resource files for all languages have been updated, compile the machine object (`.mo`) files using the `compilemessages` management command:
|
||||
|
||||
```nohighlight
|
||||
./manage.py compilemessages
|
||||
```
|
||||
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this.
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
@@ -134,3 +126,13 @@ VERSION = 'v3.3.2-dev'
|
||||
```
|
||||
|
||||
Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream.
|
||||
|
||||
### Update the Public Documentation
|
||||
|
||||
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
|
||||
|
||||
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
|
||||
|
||||
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
|
||||
|
||||
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.
|
||||
|
||||
@@ -6,17 +6,38 @@ All language translations in NetBox are generated from the source file found at
|
||||
|
||||
Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
|
||||
|
||||
Immediately prior to each NetBox release, the translation maps for all completed languages will be downloaded from Transifex, compiled, and checked into the NetBox code base by a maintainer.
|
||||
|
||||
## Updating Translation Sources
|
||||
|
||||
To update the English `.po` file from which all translations are derived, use the `makemessages` management command:
|
||||
To update the English `.po` file from which all translations are derived, use the `makemessages` management command (ignoring the `project-static/` directory):
|
||||
|
||||
```nohighlight
|
||||
./manage.py makemessages -l en
|
||||
./manage.py makemessages -l en -i "project-static/*"
|
||||
```
|
||||
|
||||
Then, commit the change and push to the `develop` branch on GitHub. After some time, any new strings will appear for translation on Transifex automatically.
|
||||
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
|
||||
|
||||
## Updating Translated Strings
|
||||
|
||||
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
|
||||
|
||||
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
|
||||
|
||||

|
||||
|
||||
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files.
|
||||
|
||||
!!! tip
|
||||
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added.
|
||||
|
||||

|
||||
|
||||
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command:
|
||||
|
||||
```nohighlight
|
||||
./manage.py compilemessages
|
||||
```
|
||||
|
||||
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
|
||||
|
||||
## Proposing New Languages
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
BIN
docs/media/development/transifex_pull_request.png
Normal file
BIN
docs/media/development/transifex_pull_request.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/media/development/transifex_sync.png
Normal file
BIN
docs/media/development/transifex_sync.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -89,13 +89,13 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dcim.models import Site
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from utilities.forms import CommentField, DynamicModelChoiceField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from .models import MyModel, MyModelStatusChoices
|
||||
|
||||
|
||||
class MyModelEditForm(NetBoxModelImportForm):
|
||||
class MyModelBulkEditForm(NetBoxModelBulkEditForm):
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# NetBox v4.0
|
||||
|
||||
## v4.0.6 (2024-06-24)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15348](https://github.com/netbox-community/netbox/issues/15348) - Show saved filters alongside quick search on object list views
|
||||
* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views
|
||||
* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard
|
||||
* [#16307](https://github.com/netbox-community/netbox/issues/16307) - Enable calling `log_*()` methods on Script without passing a message
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields
|
||||
* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules
|
||||
* [#15717](https://github.com/netbox-community/netbox/issues/15717) - Allow assigning a device/VM in a site to a cluster with no site assigned
|
||||
* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone
|
||||
* [#16149](https://github.com/netbox-community/netbox/issues/16149) - Fix object linking in custom script logs
|
||||
* [#16252](https://github.com/netbox-community/netbox/issues/16252) - Fix total count in tab at top of rack elevations view
|
||||
* [#16273](https://github.com/netbox-community/netbox/issues/16273) - Restore global search bar on mobile
|
||||
* [#16416](https://github.com/netbox-community/netbox/issues/16416) - Retain dark/light mode toggle on mobile view
|
||||
* [#16444](https://github.com/netbox-community/netbox/issues/16444) - Disable ordering circuits list by A/Z termination
|
||||
* [#16450](https://github.com/netbox-community/netbox/issues/16450) - Searching for rack unit in form dropdown should be case-insensitive
|
||||
* [#16452](https://github.com/netbox-community/netbox/issues/16452) - Fix sizing of buttons within object attribute panels
|
||||
* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar
|
||||
* [#16460](https://github.com/netbox-community/netbox/issues/16460) - Omit spaces from telephone number URLs
|
||||
* [#16512](https://github.com/netbox-community/netbox/issues/16512) - Restore a user's preferred language (if any) on login
|
||||
* [#16542](https://github.com/netbox-community/netbox/issues/16542) - Fix bulk form operations when HTMX is enabled
|
||||
* [#16702](https://github.com/netbox-community/netbox/issues/16702) - Fix validation of `return_url` query parameter
|
||||
|
||||
---
|
||||
|
||||
## v4.0.5 (2024-06-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14810](https://github.com/netbox-community/netbox/issues/14810) - Enable contact assignment for services
|
||||
* [#15489](https://github.com/netbox-community/netbox/issues/15489) - Add 1000Base-TX interface type
|
||||
* [#15873](https://github.com/netbox-community/netbox/issues/15873) - Improve readability of allocates resource numbers for clusters
|
||||
* [#16290](https://github.com/netbox-community/netbox/issues/16290) - Capture entire object in changelog data (but continue to display only non-internal attributes)
|
||||
* [#16353](https://github.com/netbox-community/netbox/issues/16353) - Enable plugins to extend object change view with custom content
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13422](https://github.com/netbox-community/netbox/issues/13422) - Rebuild MPTT trees for applicable models after merging staged changes
|
||||
* [#14567](https://github.com/netbox-community/netbox/issues/14567) - Apply active quicksearch value when exporting "current view" from object list
|
||||
* [#15194](https://github.com/netbox-community/netbox/issues/15194) - Avoid enqueuing duplicate event triggers for a modified object
|
||||
* [#16039](https://github.com/netbox-community/netbox/issues/16039) - Fix row highlighting for front & rear port connections under device view
|
||||
* [#16050](https://github.com/netbox-community/netbox/issues/16050) - Fix display of names & descriptions defined for custom scripts
|
||||
* [#16083](https://github.com/netbox-community/netbox/issues/16083) - Disable font ligatures to avoid peculiarities in rendered text
|
||||
* [#16202](https://github.com/netbox-community/netbox/issues/16202) - Fix site map button URL for certain localizations
|
||||
* [#16261](https://github.com/netbox-community/netbox/issues/16261) - Fix GraphQL filtering for certain multi-value filters
|
||||
* [#16286](https://github.com/netbox-community/netbox/issues/16286) - Fix global search support for provider accounts
|
||||
* [#16312](https://github.com/netbox-community/netbox/issues/16312) - Fix object list navigation for dashboard widgets
|
||||
* [#16315](https://github.com/netbox-community/netbox/issues/16315) - Fix filtering change log & journal entries by object type in UI
|
||||
* [#16376](https://github.com/netbox-community/netbox/issues/16376) - Update change log for the terminating object (e.g. interface) when attaching a cable
|
||||
* [#16400](https://github.com/netbox-community/netbox/issues/16400) - Fix AttributeError when attempting to restore a previous configuration revision after deleting the current one
|
||||
|
||||
---
|
||||
|
||||
## v4.0.3 (2024-05-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -104,10 +104,16 @@ class LoginView(View):
|
||||
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
||||
# create_userconfig() on user creation.)
|
||||
if not hasattr(request.user, 'config'):
|
||||
config = get_config()
|
||||
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
|
||||
request.user.config = get_config()
|
||||
UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
|
||||
|
||||
return self.redirect_to_next(request, logger)
|
||||
response = self.redirect_to_next(request, logger)
|
||||
|
||||
# Set the user's preferred language (if any)
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||
@@ -145,9 +151,10 @@ class LogoutView(View):
|
||||
logger.info(f"User {username} has logged out")
|
||||
messages.info(request, "You have logged out.")
|
||||
|
||||
# Delete session key cookie (if set) upon logout
|
||||
# Delete session key & language cookies (if set) upon logout
|
||||
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||
response.delete_cookie('session_key')
|
||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex):
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
class ProviderAccountIndex(SearchIndex):
|
||||
model = models.ProviderAccount
|
||||
fields = (
|
||||
|
||||
@@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
status = columns.ChoiceFieldColumn()
|
||||
termination_a = tables.TemplateColumn(
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
orderable=False,
|
||||
verbose_name=_('Side A')
|
||||
)
|
||||
termination_z = tables.TemplateColumn(
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
orderable=False,
|
||||
verbose_name=_('Side Z')
|
||||
)
|
||||
commit_rate = CommitRateColumn(
|
||||
|
||||
@@ -7,7 +7,7 @@ from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.query import count_related
|
||||
from utilities.views import register_model_view
|
||||
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
|
||||
@@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Provider)
|
||||
class ProviderView(generic.ObjectView):
|
||||
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount)
|
||||
class ProviderAccountView(generic.ObjectView):
|
||||
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ProviderNetwork)
|
||||
class ProviderNetworkView(generic.ObjectView):
|
||||
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(CircuitType)
|
||||
class CircuitTypeView(generic.ObjectView):
|
||||
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.query import count_related
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
|
||||
@@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(DataSource)
|
||||
class DataSourceView(generic.ObjectView):
|
||||
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DataSource.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +220,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
||||
for param in PARAMS:
|
||||
params.append((
|
||||
param.name,
|
||||
current_config.data.get(param.name, None),
|
||||
current_config.data.get(param.name, None) if current_config else None,
|
||||
candidate_config.data.get(param.name, None)
|
||||
))
|
||||
|
||||
|
||||
@@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet):
|
||||
)
|
||||
|
||||
# Enable filtering rack units by ID
|
||||
q = data['q']
|
||||
if q:
|
||||
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
|
||||
if q := data['q']:
|
||||
q = q.lower()
|
||||
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()]
|
||||
|
||||
page = self.paginate_queryset(elevation)
|
||||
if page is not None:
|
||||
|
||||
@@ -828,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100ME_FIXED = '100base-tx'
|
||||
TYPE_100ME_T1 = '100base-t1'
|
||||
TYPE_1GE_FIXED = '1000base-t'
|
||||
TYPE_1GE_TX_FIXED = '1000base-tx'
|
||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||
TYPE_2GE_FIXED = '2.5gbase-t'
|
||||
@@ -987,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
|
||||
|
||||
@@ -465,7 +465,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
selector=True
|
||||
selector=True,
|
||||
query_params={
|
||||
'site_id': ['$site', 'null']
|
||||
},
|
||||
)
|
||||
comments = CommentField()
|
||||
local_context_data = JSONField(
|
||||
|
||||
@@ -355,11 +355,11 @@ class CableTermination(ChangeLoggedModel):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Set the cable on the terminating object
|
||||
termination_model = self.termination._meta.model
|
||||
termination_model.objects.filter(pk=self.termination_id).update(
|
||||
cable=self.cable,
|
||||
cable_end=self.cable_end
|
||||
)
|
||||
termination = self.termination._meta.model.objects.get(pk=self.termination_id)
|
||||
termination.snapshot()
|
||||
termination.cable = self.cable
|
||||
termination.cable_end = self.cable_end
|
||||
termination.save()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from dcim.models import Cable
|
||||
@@ -35,7 +36,7 @@ class CableTerminationsColumn(tables.Column):
|
||||
|
||||
def render(self, value):
|
||||
links = [
|
||||
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
|
||||
f'<a href="{term.get_absolute_url()}">{escape(term)}</a>' for term in self._get_terminations(value)
|
||||
]
|
||||
return mark_safe('<br />'.join(links) or '—')
|
||||
|
||||
|
||||
@@ -43,14 +43,6 @@ MODULEBAY_STATUS = """
|
||||
"""
|
||||
|
||||
|
||||
def get_cabletermination_row_class(record):
|
||||
if record.mark_connected:
|
||||
return 'success'
|
||||
elif record.cable:
|
||||
return record.cable.get_status_color()
|
||||
return ''
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
@@ -339,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
|
||||
verbose_name=_('Mark Connected'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
row_attrs = {
|
||||
'data-name': lambda record: record.name,
|
||||
'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
|
||||
'data-cable-status': lambda record: record.cable.status if record.cable else "",
|
||||
'data-type': lambda record: record.type
|
||||
}
|
||||
|
||||
def value_link_peer(self, value):
|
||||
return ', '.join([
|
||||
f"{termination.parent_object} > {termination}" for termination in value
|
||||
@@ -386,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
|
||||
extra_buttons=CONSOLEPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.ConsolePort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
@@ -431,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
extra_buttons=CONSOLESERVERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
@@ -483,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
extra_buttons=POWERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.PowerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
||||
@@ -492,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
@@ -534,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
extra_buttons=POWEROUTLET_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.PowerOutlet
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
@@ -543,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class BaseInterfaceTable(NetBoxTable):
|
||||
@@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
extra_buttons=FRONTPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.FrontPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
|
||||
@@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
||||
@@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
|
||||
extra_buttons=REARPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||
model = models.RearPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
|
||||
@@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTable(DeviceComponentTable):
|
||||
|
||||
@@ -8,6 +8,7 @@ from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from tenancy.models import Tenant
|
||||
from utilities.data import drange
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
|
||||
class LocationTestCase(TestCase):
|
||||
@@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
|
||||
device2.full_clean()
|
||||
device2.save()
|
||||
|
||||
def test_device_mismatched_site_cluster(self):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_type, site=None),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
device_type = DeviceType.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
# Device with site only should pass
|
||||
Device(name='device1', site=sites[0], device_type=device_type, role=device_role).full_clean()
|
||||
|
||||
# Device with site, cluster non-site should pass
|
||||
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[2]).full_clean()
|
||||
|
||||
# Device with mismatched site & cluster should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean()
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.models import ASN, IPAddress, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
@@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import count_related
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||
from utilities.views import (
|
||||
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||
)
|
||||
from virtualization.filtersets import VirtualMachineFilterSet
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.tables import VirtualMachineTable
|
||||
@@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Region)
|
||||
class RegionView(generic.ObjectView):
|
||||
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Region.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
regions = instance.get_descendants(include_self=True)
|
||||
related_models = (
|
||||
(Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'),
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
regions,
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(SiteGroup)
|
||||
class SiteGroupView(generic.ObjectView):
|
||||
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
groups = instance.get_descendants(include_self=True)
|
||||
related_models = (
|
||||
(Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
groups,
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Site)
|
||||
class SiteView(generic.ObjectView):
|
||||
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Site.objects.prefetch_related('tenant__group')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
# DCIM
|
||||
(Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
(Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
# Virtualization
|
||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
|
||||
# IPAM
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=instance.pk
|
||||
), 'site'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
# Circuits
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
[CableTermination, CircuitTermination],
|
||||
(
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=instance.pk
|
||||
), 'site'),
|
||||
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
|
||||
'site_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Location)
|
||||
class LocationView(generic.ObjectView):
|
||||
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
locations = instance.get_descendants(include_self=True)
|
||||
related_models = (
|
||||
(Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
||||
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, locations, [CableTermination]),
|
||||
}
|
||||
|
||||
|
||||
@@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(RackRole)
|
||||
class RackRoleView(generic.ObjectView):
|
||||
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Rack)
|
||||
class RackView(generic.ObjectView):
|
||||
class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'),
|
||||
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
|
||||
)
|
||||
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||
|
||||
if instance.location:
|
||||
@@ -679,7 +665,7 @@ class RackView(generic.ObjectView):
|
||||
])
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
@@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Manufacturer)
|
||||
class ManufacturerView(generic.ObjectView):
|
||||
class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
||||
(ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
||||
(InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
||||
(Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]),
|
||||
}
|
||||
|
||||
|
||||
@@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(DeviceType)
|
||||
class DeviceTypeView(generic.ObjectView):
|
||||
class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DeviceType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, omit=[
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
|
||||
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
|
||||
RearPortTemplate,
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
@@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ModuleType)
|
||||
class ModuleTypeView(generic.ObjectView):
|
||||
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ModuleType.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, omit=[
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
|
||||
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
|
||||
RearPortTemplate,
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
@@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(DeviceRole)
|
||||
class DeviceRoleView(generic.ObjectView):
|
||||
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Platform)
|
||||
class PlatformView(generic.ObjectView):
|
||||
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Platform.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
|
||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Module)
|
||||
class ModuleView(generic.ObjectView):
|
||||
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Module.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
(RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -3451,8 +3410,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
||||
if membership_form.is_valid():
|
||||
|
||||
membership_form.save()
|
||||
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
||||
messages.success(request, mark_safe(msg))
|
||||
messages.success(request, mark_safe(
|
||||
f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
||||
))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
@@ -3552,16 +3512,12 @@ class PowerPanelListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(PowerPanel)
|
||||
class PowerPanelView(generic.ObjectView):
|
||||
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -3665,16 +3621,18 @@ class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext)
|
||||
class VirtualDeviceContextView(generic.ObjectView):
|
||||
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
||||
changed_object = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
prechange_data = serializers.JSONField(
|
||||
source='prechange_data_clean',
|
||||
read_only=True,
|
||||
allow_null=True
|
||||
)
|
||||
postchange_data = serializers.JSONField(
|
||||
source='postchange_data_clean',
|
||||
read_only=True,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
|
||||
@@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet):
|
||||
|
||||
ORDERING_NEWEST = '-created'
|
||||
ORDERING_OLDEST = 'created'
|
||||
ORDERING_ALPHABETICAL_AZ = 'name'
|
||||
ORDERING_ALPHABETICAL_ZA = '-name'
|
||||
|
||||
CHOICES = (
|
||||
(ORDERING_NEWEST, _('Newest')),
|
||||
(ORDERING_OLDEST, _('Oldest')),
|
||||
(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
|
||||
(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
|
||||
)
|
||||
|
||||
#
|
||||
|
||||
@@ -135,23 +135,23 @@ class ConditionSet:
|
||||
def __init__(self, ruleset):
|
||||
if type(ruleset) is not dict:
|
||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||
if len(ruleset) != 1:
|
||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
||||
ruleset=len(ruleset)))
|
||||
|
||||
# Determine the logic type
|
||||
logic = list(ruleset.keys())[0]
|
||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
||||
logic=logic, op_and=AND, op_or=OR
|
||||
))
|
||||
self.logic = logic.lower()
|
||||
if len(ruleset) == 1:
|
||||
self.logic = (list(ruleset.keys())[0]).lower()
|
||||
if self.logic not in (AND, OR):
|
||||
raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
|
||||
|
||||
# Compile the set of Conditions
|
||||
self.conditions = [
|
||||
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
||||
for rule in ruleset[self.logic]
|
||||
]
|
||||
# Compile the set of Conditions
|
||||
self.conditions = [
|
||||
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
||||
for rule in ruleset[self.logic]
|
||||
]
|
||||
else:
|
||||
try:
|
||||
self.logic = None
|
||||
self.conditions = [Condition(**ruleset)]
|
||||
except TypeError:
|
||||
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
|
||||
|
||||
def eval(self, data):
|
||||
"""
|
||||
|
||||
@@ -13,13 +13,14 @@ def event_tracking(request):
|
||||
:param request: WSGIRequest object with a unique `id` set
|
||||
"""
|
||||
current_request.set(request)
|
||||
events_queue.set([])
|
||||
events_queue.set({})
|
||||
|
||||
yield
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_events(events_queue.get())
|
||||
if events := list(events_queue.get().values()):
|
||||
flush_events(events)
|
||||
|
||||
# Clear context vars
|
||||
current_request.set(None)
|
||||
events_queue.set([])
|
||||
events_queue.set({})
|
||||
|
||||
@@ -265,6 +265,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
parameters = self.config.get('url_params') or {}
|
||||
if page_size := self.config.get('page_size'):
|
||||
parameters['per_page'] = page_size
|
||||
parameters['embedded'] = True
|
||||
|
||||
if parameters:
|
||||
try:
|
||||
@@ -380,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
|
||||
if request.user.is_anonymous:
|
||||
bookmarks = list()
|
||||
else:
|
||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||
user_bookmarks = Bookmark.objects.filter(user=request.user)
|
||||
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
|
||||
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
|
||||
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
|
||||
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
|
||||
else:
|
||||
bookmarks = user_bookmarks.order_by(self.config['order_by'])
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
conent_types = ObjectType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||
content_types = ObjectType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=content_types)
|
||||
if max_items := self.config.get('max_items'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
|
||||
|
||||
@@ -58,15 +58,21 @@ def enqueue_object(queue, instance, user, request_id, action):
|
||||
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
|
||||
return
|
||||
|
||||
queue.append({
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event': action,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, action),
|
||||
'username': user.username,
|
||||
'request_id': request_id
|
||||
})
|
||||
assert instance.pk is not None
|
||||
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||
if key in queue:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
else:
|
||||
queue[key] = {
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event': action,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, action),
|
||||
'username': user.username,
|
||||
'request_id': request_id
|
||||
}
|
||||
|
||||
|
||||
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
||||
@@ -163,14 +169,14 @@ def process_event_queue(events):
|
||||
)
|
||||
|
||||
|
||||
def flush_events(queue):
|
||||
def flush_events(events):
|
||||
"""
|
||||
Flush a list of object representation to RQ for webhook processing.
|
||||
Flush a list of object representations to RQ for event processing.
|
||||
"""
|
||||
if queue:
|
||||
if events:
|
||||
for name in settings.EVENTS_PIPELINE:
|
||||
try:
|
||||
func = import_string(name)
|
||||
func(queue)
|
||||
func(events)
|
||||
except Exception as e:
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
||||
@@ -464,13 +464,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('User')
|
||||
)
|
||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ObjectType.objects.all(),
|
||||
assigned_object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('journaling'),
|
||||
required=False,
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
kind = forms.ChoiceField(
|
||||
label=_('Kind'),
|
||||
@@ -507,11 +504,8 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
required=False,
|
||||
label=_('User')
|
||||
)
|
||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ObjectType.objects.all(),
|
||||
changed_object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('change_logging'),
|
||||
required=False,
|
||||
label=_('Object Type'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from utilities.data import shallow_compare_dict
|
||||
from ..querysets import ObjectChangeQuerySet
|
||||
|
||||
__all__ = (
|
||||
@@ -136,6 +141,71 @@ class ObjectChange(models.Model):
|
||||
def get_action_color(self):
|
||||
return ObjectChangeActionChoices.colors.get(self.action)
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def has_changes(self):
|
||||
return self.prechange_data != self.postchange_data
|
||||
|
||||
@cached_property
|
||||
def diff_exclude_fields(self):
|
||||
"""
|
||||
Return a set of attributes which should be ignored when calculating a diff
|
||||
between the pre- and post-change data. (For instance, it would not make
|
||||
sense to compare the "last updated" times as these are expected to differ.)
|
||||
"""
|
||||
model = self.changed_object_type.model_class()
|
||||
attrs = set()
|
||||
|
||||
# Exclude auto-populated change tracking fields
|
||||
if issubclass(model, ChangeLoggingMixin):
|
||||
attrs.update({'created', 'last_updated'})
|
||||
|
||||
# Exclude MPTT-internal fields
|
||||
if issubclass(model, MPTTModel):
|
||||
attrs.update({'level', 'lft', 'rght', 'tree_id'})
|
||||
|
||||
return attrs
|
||||
|
||||
def get_clean_data(self, prefix):
|
||||
"""
|
||||
Return only the pre-/post-change attributes which are relevant for calculating a diff.
|
||||
"""
|
||||
ret = {}
|
||||
change_data = getattr(self, f'{prefix}_data') or {}
|
||||
for k, v in change_data.items():
|
||||
if k not in self.diff_exclude_fields and not k.startswith('_'):
|
||||
ret[k] = v
|
||||
return ret
|
||||
|
||||
@cached_property
|
||||
def prechange_data_clean(self):
|
||||
return self.get_clean_data('prechange')
|
||||
|
||||
@cached_property
|
||||
def postchange_data_clean(self):
|
||||
return self.get_clean_data('postchange')
|
||||
|
||||
def diff(self):
|
||||
"""
|
||||
Return a dictionary of pre- and post-change values for attributes which have changed.
|
||||
"""
|
||||
prechange_data = self.prechange_data_clean
|
||||
postchange_data = self.postchange_data_clean
|
||||
|
||||
# Determine which attributes have changed
|
||||
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
changed_attrs = sorted(postchange_data.keys())
|
||||
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||
changed_attrs = sorted(prechange_data.keys())
|
||||
else:
|
||||
# TODO: Support deep (recursive) comparison
|
||||
changed_data = shallow_compare_dict(prechange_data, postchange_data)
|
||||
changed_attrs = sorted(changed_data.keys())
|
||||
|
||||
return {
|
||||
'pre': {
|
||||
k: prechange_data.get(k) for k in changed_attrs
|
||||
},
|
||||
'post': {
|
||||
k: postchange_data.get(k) for k in changed_attrs
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -520,7 +521,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
RegexValidator(
|
||||
regex=self.validation_regex,
|
||||
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
||||
regex=self.validation_regex
|
||||
regex=escape(self.validation_regex)
|
||||
))
|
||||
)
|
||||
]
|
||||
@@ -660,6 +661,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
# Validate date & time
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
if type(value) is not datetime:
|
||||
# Work around UTC issue for Python < 3.11; see
|
||||
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
|
||||
if type(value) is str and value.endswith('Z'):
|
||||
value = f'{value[:-1]}+00:00'
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models, transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from extras.choices import ChangeActionChoices
|
||||
from netbox.models import ChangeLoggedModel
|
||||
@@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
|
||||
instance = self.model.objects.get(pk=self.object_id)
|
||||
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
||||
instance.delete()
|
||||
|
||||
# Rebuild the MPTT tree where applicable
|
||||
if issubclass(self.model, MPTTModel):
|
||||
self.model.objects.rebuild()
|
||||
|
||||
apply.alters_data = True
|
||||
|
||||
def get_action_color(self):
|
||||
|
||||
@@ -480,19 +480,21 @@ class BaseScript:
|
||||
# A test method is currently active, so log the message using legacy Report logging
|
||||
if self._current_test:
|
||||
|
||||
# TODO: Use a dataclass for test method logs
|
||||
self.tests[self._current_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
str(message),
|
||||
))
|
||||
|
||||
# Increment the event counter for this level
|
||||
if level in self.tests[self._current_test]:
|
||||
self.tests[self._current_test][level] += 1
|
||||
|
||||
# Record message (if any) to the report log
|
||||
if message:
|
||||
# TODO: Use a dataclass for test method logs
|
||||
self.tests[self._current_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
str(message),
|
||||
))
|
||||
|
||||
elif message:
|
||||
|
||||
# Record to the script's log
|
||||
@@ -500,6 +502,8 @@ class BaseScript:
|
||||
'time': timezone.now().isoformat(),
|
||||
'status': level,
|
||||
'message': str(message),
|
||||
'obj': str(obj) if obj else None,
|
||||
'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
})
|
||||
|
||||
# Record to the system log
|
||||
@@ -507,19 +511,19 @@ class BaseScript:
|
||||
message = f"{obj}: {message}"
|
||||
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
|
||||
|
||||
def log_debug(self, message, obj=None):
|
||||
def log_debug(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
|
||||
|
||||
def log_success(self, message, obj=None):
|
||||
def log_success(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
|
||||
|
||||
def log_info(self, message, obj=None):
|
||||
def log_info(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
|
||||
|
||||
def log_warning(self, message, obj=None):
|
||||
def log_warning(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
|
||||
|
||||
def log_failure(self, message, obj=None):
|
||||
def log_failure(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
|
||||
self.failed = True
|
||||
|
||||
|
||||
@@ -55,18 +55,6 @@ def run_validators(instance, validators):
|
||||
clear_events = Signal()
|
||||
|
||||
|
||||
def is_same_object(instance, webhook_data, request_id):
|
||||
"""
|
||||
Compare the given instance to the most recent queued webhook object, returning True
|
||||
if they match. This check is used to avoid creating duplicate webhook entries.
|
||||
"""
|
||||
return (
|
||||
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
|
||||
instance.pk == webhook_data['object_id'] and
|
||||
request_id == webhook_data['request_id']
|
||||
)
|
||||
|
||||
|
||||
@receiver((post_save, m2m_changed))
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
# Ensure that we're working with fresh M2M assignments
|
||||
if m2m_changed:
|
||||
instance.refresh_from_db()
|
||||
|
||||
# Enqueue the object for event processing
|
||||
queue = events_queue.get()
|
||||
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
|
||||
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
|
||||
queue[-1]['data'] = serialize_for_event(instance)
|
||||
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
else:
|
||||
enqueue_object(queue, instance, request.user, request.id, action)
|
||||
enqueue_object(queue, instance, request.user, request.id, action)
|
||||
events_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
|
||||
# Enqueue webhooks
|
||||
# Enqueue the object for event processing
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
events_queue.set(queue)
|
||||
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
|
||||
"""
|
||||
logger = logging.getLogger('events')
|
||||
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
|
||||
events_queue.set([])
|
||||
events_queue.set({})
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.models import *
|
||||
@@ -545,6 +546,9 @@ class ScriptResultsTable(BaseTable):
|
||||
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
||||
verbose_name=_('Level')
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object')
|
||||
)
|
||||
message = columns.MarkdownColumn(
|
||||
verbose_name=_('Message')
|
||||
)
|
||||
@@ -552,8 +556,17 @@ class ScriptResultsTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
empty_text = _(EMPTY_TABLE_TEXT)
|
||||
fields = (
|
||||
'index', 'time', 'status', 'message',
|
||||
'index', 'time', 'status', 'object', 'message',
|
||||
)
|
||||
default_columns = (
|
||||
'index', 'time', 'status', 'object', 'message',
|
||||
)
|
||||
|
||||
def render_object(self, value, record):
|
||||
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||
|
||||
def render_url(self, value):
|
||||
return format_html("<a href='{}'>{}</a>", value, value)
|
||||
|
||||
|
||||
class ReportResultsTable(BaseTable):
|
||||
@@ -585,3 +598,9 @@ class ReportResultsTable(BaseTable):
|
||||
fields = (
|
||||
'index', 'method', 'time', 'status', 'object', 'url', 'message',
|
||||
)
|
||||
|
||||
def render_object(self, value, record):
|
||||
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||
|
||||
def render_url(self, value):
|
||||
return format_html("<a href='{}'>{}</a>", value, value)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import template
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from core.models import ObjectType
|
||||
@@ -59,8 +60,7 @@ def custom_links(context, obj):
|
||||
# Add non-grouped links
|
||||
else:
|
||||
try:
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
if rendered := cl.render(link_context):
|
||||
template_code += LINK_BUTTON.format(
|
||||
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
||||
)
|
||||
@@ -75,8 +75,7 @@ def custom_links(context, obj):
|
||||
|
||||
for cl in links:
|
||||
try:
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
if rendered := cl.render(link_context):
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
|
||||
)
|
||||
@@ -88,7 +87,7 @@ def custom_links(context, obj):
|
||||
|
||||
if links_rendered:
|
||||
template_code += GROUP_BUTTON.format(
|
||||
links[0].button_class, group, ''.join(links_rendered)
|
||||
links[0].button_class, escape(group), ''.join(links_rendered)
|
||||
)
|
||||
|
||||
return mark_safe(template_code)
|
||||
|
||||
@@ -75,6 +75,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.postchange_data)
|
||||
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||
|
||||
def test_update_object(self):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
@@ -112,6 +116,12 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.prechange_data)
|
||||
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||
self.assertIn('_name', oc.postchange_data)
|
||||
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||
|
||||
def test_delete_object(self):
|
||||
site = Site(
|
||||
name='Site 1',
|
||||
@@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
self.assertEqual(oc.postchange_data, None)
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.prechange_data)
|
||||
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||
|
||||
def test_bulk_update_objects(self):
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
|
||||
@@ -338,6 +352,10 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.postchange_data)
|
||||
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||
|
||||
def test_update_object(self):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
@@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.prechange_data)
|
||||
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||
self.assertIn('_name', oc.postchange_data)
|
||||
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||
|
||||
def test_delete_object(self):
|
||||
site = Site(
|
||||
name='Site 1',
|
||||
@@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
|
||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||
self.assertEqual(oc.postchange_data, None)
|
||||
|
||||
# Check that private attributes were included in raw data but not display data
|
||||
self.assertIn('_name', oc.prechange_data)
|
||||
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||
|
||||
def test_bulk_create_objects(self):
|
||||
data = (
|
||||
{
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.conditions import Condition, ConditionSet
|
||||
from extras.events import serialize_for_event
|
||||
from extras.forms import EventRuleForm
|
||||
from extras.models import EventRule, Webhook
|
||||
|
||||
|
||||
class ConditionTestCase(TestCase):
|
||||
@@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
|
||||
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
||||
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
||||
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
||||
|
||||
def test_event_rule_conditions_without_logic_operator(self):
|
||||
"""
|
||||
Test evaluation of EventRule conditions without logic operator.
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
'attr': 'status.value',
|
||||
'value': 'active',
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status='active')
|
||||
self.assertTrue(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_logical_operation(self):
|
||||
"""
|
||||
Test evaluation of EventRule conditions without logic operator, but with logical operation (in).
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
"attr": "status.value",
|
||||
"value": ["planned", "staging"],
|
||||
"op": "in",
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status in ['planned, 'staging'])
|
||||
self.assertFalse(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_logical_operation_and_negate(self):
|
||||
"""
|
||||
Test evaluation of EventRule with logical operation (in) and negate.
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
"attr": "status.value",
|
||||
"value": ["planned", "staging"],
|
||||
"op": "in",
|
||||
"negate": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status NOT in ['planned, 'staging'])
|
||||
self.assertTrue(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_incorrect_key_must_return_false(self):
|
||||
"""
|
||||
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
|
||||
"""
|
||||
|
||||
ct = ContentType.objects.get(app_label='extras', model='webhook')
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
|
||||
form = EventRuleForm({
|
||||
"name": "Event Rule 1",
|
||||
"type_create": True,
|
||||
"type_update": True,
|
||||
"action_object_type": ct.pk,
|
||||
"action_type": "webhook",
|
||||
"action_choice": webhook.pk,
|
||||
"content_types": [site_ct.pk],
|
||||
"conditions": {
|
||||
"foo": "status.value",
|
||||
"value": "active"
|
||||
}
|
||||
})
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import patch
|
||||
|
||||
import django_rq
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from requests import Session
|
||||
from rest_framework import status
|
||||
@@ -12,6 +13,7 @@ from core.models import ObjectType
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
||||
from extras.context_managers import event_tracking
|
||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||
from extras.models import EventRule, Tag, Webhook
|
||||
from extras.webhooks import generate_signature, send_webhook
|
||||
@@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
|
||||
return HttpResponse()
|
||||
|
||||
# Enqueue a webhook for processing
|
||||
webhooks_queue = []
|
||||
webhooks_queue = {}
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
enqueue_object(
|
||||
webhooks_queue,
|
||||
@@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
|
||||
request_id=request_id,
|
||||
action=ObjectChangeActionChoices.ACTION_CREATE
|
||||
)
|
||||
flush_events(webhooks_queue)
|
||||
flush_events(list(webhooks_queue.values()))
|
||||
|
||||
# Retrieve the job from queue
|
||||
job = self.queue.jobs[0]
|
||||
@@ -377,3 +379,24 @@ class EventRuleTest(APITestCase):
|
||||
# Patch the Session object with our dummy_send() method, then process the webhook for sending
|
||||
with patch.object(Session, 'send', dummy_send) as mock_send:
|
||||
send_webhook(**job.kwargs)
|
||||
|
||||
def test_duplicate_triggers(self):
|
||||
"""
|
||||
Test for erroneous duplicate event triggers resulting from saving an object multiple times
|
||||
within the span of a single request.
|
||||
"""
|
||||
url = reverse('dcim:site_add')
|
||||
request = RequestFactory().get(url)
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
|
||||
|
||||
with event_tracking(request):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
# Save the site a second time
|
||||
site.save()
|
||||
|
||||
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
|
||||
|
||||
@@ -723,15 +723,15 @@ class ObjectChangeView(generic.ObjectView):
|
||||
|
||||
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
||||
non_atomic_change = True
|
||||
prechange_data = prev_change.postchange_data
|
||||
prechange_data = prev_change.postchange_data_clean
|
||||
else:
|
||||
non_atomic_change = False
|
||||
prechange_data = instance.prechange_data
|
||||
prechange_data = instance.prechange_data_clean
|
||||
|
||||
if prechange_data and instance.postchange_data:
|
||||
diff_added = shallow_compare_dict(
|
||||
prechange_data or dict(),
|
||||
instance.postchange_data or dict(),
|
||||
instance.postchange_data_clean or dict(),
|
||||
exclude=['last_updated'],
|
||||
)
|
||||
diff_removed = {
|
||||
@@ -1201,6 +1201,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
'time': log.get('time'),
|
||||
'status': log.get('status'),
|
||||
'message': log.get('message'),
|
||||
'object': log.get('obj'),
|
||||
'url': log.get('url'),
|
||||
}
|
||||
data.append(result)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ContactsMixin
|
||||
from utilities.data import array_to_string
|
||||
|
||||
__all__ = (
|
||||
@@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
|
||||
return reverse('ipam:servicetemplate', args=[self.pk])
|
||||
|
||||
|
||||
class Service(ServiceBase, PrimaryModel):
|
||||
class Service(ContactsMixin, ServiceBase, PrimaryModel):
|
||||
"""
|
||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
||||
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
||||
|
||||
@@ -649,7 +649,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
graphql_filter = {
|
||||
'address': '192.168.0.1/24',
|
||||
'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -12,7 +12,7 @@ from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
@@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VRF)
|
||||
class VRFView(generic.ObjectView):
|
||||
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VRF.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
||||
(IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
||||
)
|
||||
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
instance.import_targets.all(),
|
||||
orderable=False
|
||||
@@ -53,7 +48,7 @@ class VRFView(generic.ObjectView):
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
|
||||
'import_targets_table': import_targets_table,
|
||||
'export_targets_table': export_targets_table,
|
||||
}
|
||||
@@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(RIR)
|
||||
class RIRView(generic.ObjectView):
|
||||
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = RIR.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ASN)
|
||||
class ASNView(generic.ObjectView):
|
||||
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ASN.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Role)
|
||||
class RoleView(generic.ObjectView):
|
||||
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Role.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
(IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VLANGroup)
|
||||
class VLANGroupView(generic.ObjectView):
|
||||
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -1280,3 +1263,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
table = tables.ServiceTable
|
||||
|
||||
|
||||
@register_model_view(Service, 'contacts')
|
||||
class ServiceContactsView(ObjectContactsView):
|
||||
queryset = Service.objects.all()
|
||||
|
||||
@@ -7,4 +7,4 @@ __all__ = (
|
||||
|
||||
|
||||
current_request = ContextVar('current_request', default=None)
|
||||
events_queue = ContextVar('events_queue', default=[])
|
||||
events_queue = ContextVar('events_queue', default=dict())
|
||||
|
||||
@@ -23,8 +23,9 @@ def map_strawberry_type(field):
|
||||
elif isinstance(field, MultiValueArrayFilter):
|
||||
pass
|
||||
elif isinstance(field, MultiValueCharFilter):
|
||||
should_create_function = True
|
||||
attr_type = List[str] | None
|
||||
# Note: Need to use the legacy FilterLookup from filters, not from
|
||||
# strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
|
||||
attr_type = strawberry_django.filters.FilterLookup[str] | None
|
||||
elif isinstance(field, MultiValueDateFilter):
|
||||
attr_type = auto
|
||||
elif isinstance(field, MultiValueDateTimeFilter):
|
||||
|
||||
@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '4.0.3'
|
||||
VERSION = '4.0.6'
|
||||
HOSTNAME = platform.node()
|
||||
# Set the base directory two levels up
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -368,6 +368,8 @@ INSTALLED_APPS = [
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
]
|
||||
if not DEBUG:
|
||||
INSTALLED_APPS.remove('debug_toolbar')
|
||||
if not DJANGO_ADMIN_ENABLED:
|
||||
INSTALLED_APPS.remove('django.contrib.admin')
|
||||
|
||||
@@ -549,7 +551,7 @@ if SENTRY_ENABLED:
|
||||
|
||||
# Calculate a unique deployment ID from the secret key
|
||||
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
|
||||
CENSUS_URL = 'https://census.netbox.dev/api/v1/'
|
||||
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
|
||||
CENSUS_PARAMS = {
|
||||
'version': VERSION,
|
||||
'python_version': sys.version.split()[0],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import zoneinfo
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
@@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
|
||||
|
||||
def render(self, value):
|
||||
if value:
|
||||
current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
value = value.astimezone(current_tz)
|
||||
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
|
||||
|
||||
def value(self, value):
|
||||
@@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
|
||||
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
|
||||
for k, v in self.url_params.items()
|
||||
])
|
||||
return mark_safe(f'<a href="{url}">{value}</a>')
|
||||
return mark_safe(f'<a href="{url}">{escape(value)}</a>')
|
||||
return value
|
||||
|
||||
def value(self, value):
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -27,10 +27,10 @@
|
||||
"bootstrap": "5.3.3",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "10.1.2",
|
||||
"gridstack": "10.2.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.0.0",
|
||||
"sass": "1.77.2",
|
||||
"sass": "1.77.6",
|
||||
"tom-select": "2.3.1",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
30
netbox/project-static/src/forms/savedFiltersSelect.ts
Normal file
30
netbox/project-static/src/forms/savedFiltersSelect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { isTruthy } from '../util';
|
||||
|
||||
/**
|
||||
* Handle saved filter change event.
|
||||
*
|
||||
* @param event "change" event for the saved filter select
|
||||
*/
|
||||
function handleSavedFilterChange(event: Event): void {
|
||||
const savedFilter = event.currentTarget as HTMLSelectElement;
|
||||
let baseUrl = savedFilter.baseURI.split('?')[0];
|
||||
const preFilter = '?';
|
||||
|
||||
const selectedOptions = Array.from(savedFilter.options)
|
||||
.filter(option => option.selected)
|
||||
.map(option => `filter_id=${option.value}`)
|
||||
.join('&');
|
||||
|
||||
baseUrl += `${preFilter}${selectedOptions}`;
|
||||
document.location.href = baseUrl;
|
||||
}
|
||||
|
||||
export function initSavedFilterSelect(): void {
|
||||
const divResults = document.getElementById('results');
|
||||
if (isTruthy(divResults)) {
|
||||
const savedFilterSelect = document.getElementById('id_filter_id');
|
||||
if (isTruthy(savedFilterSelect)) {
|
||||
savedFilterSelect.addEventListener('change', handleSavedFilterChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { initSideNav } from './sidenav';
|
||||
import { initDashboard } from './dashboard';
|
||||
import { initRackElevation } from './racks';
|
||||
import { initHtmx } from './htmx';
|
||||
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
|
||||
|
||||
function initDocument(): void {
|
||||
for (const init of [
|
||||
@@ -31,6 +32,7 @@ function initDocument(): void {
|
||||
initDashboard,
|
||||
initRackElevation,
|
||||
initHtmx,
|
||||
initSavedFilterSelect,
|
||||
]) {
|
||||
init();
|
||||
}
|
||||
|
||||
@@ -7,38 +7,74 @@ import { isTruthy } from './util';
|
||||
*/
|
||||
function quickSearchEventHandler(event: Event): void {
|
||||
const quicksearch = event.currentTarget as HTMLInputElement;
|
||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
|
||||
const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
|
||||
if (isTruthy(clearbtn)) {
|
||||
if (quicksearch.value === "") {
|
||||
clearbtn.classList.add("invisible");
|
||||
if (quicksearch.value === '') {
|
||||
clearbtn.classList.add('invisible');
|
||||
} else {
|
||||
clearbtn.classList.remove("invisible");
|
||||
clearbtn.classList.remove('invisible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the existing search parameters in the link to export Current View.
|
||||
*/
|
||||
function clearLinkParams(): void {
|
||||
const link = document.getElementById('export_current_view') as HTMLLinkElement;
|
||||
const linkUpdated = link?.href.split('&')[0];
|
||||
link.setAttribute('href', linkUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Export View link to add the Quick Search parameters.
|
||||
* @param event
|
||||
*/
|
||||
function handleQuickSearchParams(event: Event): void {
|
||||
const quickSearchParameters = event.currentTarget as HTMLInputElement;
|
||||
|
||||
// Clear the existing search parameters
|
||||
clearLinkParams();
|
||||
|
||||
if (quickSearchParameters != null) {
|
||||
const link = document.getElementById('export_current_view') as HTMLLinkElement;
|
||||
const search_parameter = `q=${quickSearchParameters.value}`;
|
||||
const linkUpdated = link?.href + '&' + search_parameter;
|
||||
link.setAttribute('href', linkUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Quicksearch Event listener/handlers.
|
||||
*/
|
||||
export function initQuickSearch(): void {
|
||||
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
|
||||
const quicksearch = document.getElementById('quicksearch') as HTMLInputElement;
|
||||
const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
|
||||
if (isTruthy(quicksearch)) {
|
||||
quicksearch.addEventListener("keyup", quickSearchEventHandler, {
|
||||
passive: true
|
||||
})
|
||||
quicksearch.addEventListener("search", quickSearchEventHandler, {
|
||||
passive: true
|
||||
})
|
||||
quicksearch.addEventListener('keyup', quickSearchEventHandler, {
|
||||
passive: true,
|
||||
});
|
||||
quicksearch.addEventListener('search', quickSearchEventHandler, {
|
||||
passive: true,
|
||||
});
|
||||
quicksearch.addEventListener('change', handleQuickSearchParams, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
if (isTruthy(clearbtn)) {
|
||||
clearbtn.addEventListener("click", async () => {
|
||||
const search = new Event('search');
|
||||
quicksearch.value = '';
|
||||
await new Promise(f => setTimeout(f, 100));
|
||||
quicksearch.dispatchEvent(search);
|
||||
}, {
|
||||
passive: true
|
||||
})
|
||||
clearbtn.addEventListener(
|
||||
'click',
|
||||
async () => {
|
||||
const search = new Event('search');
|
||||
quicksearch.value = '';
|
||||
await new Promise(f => setTimeout(f, 100));
|
||||
quicksearch.dispatchEvent(search);
|
||||
clearLinkParams();
|
||||
},
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Serialized data from change records
|
||||
pre.change-data {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
// Display each line individually for highlighting
|
||||
> span {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Overrides of external libraries
|
||||
@import 'overrides/bootstrap';
|
||||
@import 'overrides/tabler';
|
||||
@import 'overrides/tomselect';
|
||||
|
||||
// Transitional styling to ease migration of templates from NetBox v3.x
|
||||
@import 'transitional/badges';
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// Disable font-ligatures for Chromium based browsers
|
||||
// Chromium requires `font-variant-ligatures: none` in addition to `font-feature-settings "liga" 0`
|
||||
* {
|
||||
font-feature-settings: "liga" 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
// Restore default foreground & background colors for <pre> blocks
|
||||
pre {
|
||||
background-color: transparent;
|
||||
@@ -32,3 +39,8 @@ table a {
|
||||
// Adjust table anchor link contrast as not enough contrast in dark mode
|
||||
filter: brightness(110%);
|
||||
}
|
||||
|
||||
// Override background color alpha value
|
||||
[data-bs-theme=dark] ::selection {
|
||||
background-color: rgba(var(--tblr-primary-rgb),.48)
|
||||
}
|
||||
|
||||
8
netbox/project-static/styles/overrides/_tomselect.scss
Normal file
8
netbox/project-static/styles/overrides/_tomselect.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.ts-wrapper.multi {
|
||||
.ts-control {
|
||||
padding: 7px 7px 3px 7px;
|
||||
div {
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1754,10 +1754,10 @@ graphql@16.8.1:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
||||
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
||||
|
||||
gridstack@10.1.2:
|
||||
version "10.1.2"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.1.2.tgz#58b5ae0057a8aa5e4f6563041c4ca2def3aa4268"
|
||||
integrity sha512-Nn27XGQ68WtBC513cKQQ4t/dA2uuN/xnNUU50puXEJv6IFk5SzT0Dnsq68GpopO1n0tXUKZKm1Rw7uOUMDz1KQ==
|
||||
gridstack@10.2.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca"
|
||||
integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.77.2:
|
||||
version "1.77.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.2.tgz#18d4ed2eefc260cdc8099c5439ec1303fd5863aa"
|
||||
integrity sha512-eb4GZt1C3avsX3heBNlrc7I09nyT00IUuo4eFhAbeXWU2fvA7oXI53SxODVAA+zgZCk9aunAZgO+losjR3fAwA==
|
||||
sass@1.77.6:
|
||||
version "1.77.6"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4"
|
||||
integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
|
||||
@@ -35,6 +35,7 @@ Blocks:
|
||||
|
||||
{# User menu (mobile view) #}
|
||||
<div class="navbar-nav flex-row d-lg-none">
|
||||
{% include 'inc/light_toggle.html' %}
|
||||
{% include 'inc/user_menu.html' %}
|
||||
</div>
|
||||
|
||||
@@ -52,14 +53,7 @@ Blocks:
|
||||
|
||||
<div class="navbar-nav flex-row align-items-center order-md-last">
|
||||
{# Dark/light mode toggle #}
|
||||
<div class="d-none d-md-flex">
|
||||
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||
<i class="mdi mdi-lightbulb"></i>
|
||||
</button>
|
||||
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||
<i class="mdi mdi-lightbulb-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include 'inc/light_toggle.html' %}
|
||||
|
||||
{# User menu #}
|
||||
{% include 'inc/user_menu.html' %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
@@ -27,7 +28,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td class="d-flex justify-content-between">
|
||||
<td class="d-flex justify-content-between align-items-start">
|
||||
{% if object.rack %}
|
||||
{{ object.rack|linkify }}
|
||||
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
|
||||
@@ -63,7 +64,7 @@
|
||||
{% if object.latitude and object.longitude %}
|
||||
{% if config.MAPS_URL %}
|
||||
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
|
||||
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load plugins %}
|
||||
{% load tz %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
@@ -72,7 +73,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Physical Address" %}</th>
|
||||
<td class="d-flex justify-content-between">
|
||||
<td class="d-flex justify-content-between align-items-start">
|
||||
{% if object.physical_address %}
|
||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||
{% if config.MAPS_URL %}
|
||||
@@ -95,7 +96,7 @@
|
||||
{% if object.latitude and object.longitude %}
|
||||
{% if config.MAPS_URL %}
|
||||
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
|
||||
<a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
|
||||
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object }}{% endblock %}
|
||||
@@ -22,7 +23,7 @@
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Change" %}</h5>
|
||||
@@ -104,7 +105,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
|
||||
@@ -112,7 +113,7 @@
|
||||
{% if object.prechange_data %}
|
||||
{% spaceless %}
|
||||
<pre class="change-data">
|
||||
{% for k, v in object.prechange_data.items %}
|
||||
{% for k, v in object.prechange_data_clean.items %}
|
||||
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||
{% endfor %}
|
||||
</pre>
|
||||
@@ -132,7 +133,7 @@
|
||||
{% if object.postchange_data %}
|
||||
{% spaceless %}
|
||||
<pre class="change-data">
|
||||
{% for k, v in object.postchange_data.items %}
|
||||
{% for k, v in object.postchange_data_clean.items %}
|
||||
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||
{% endfor %}
|
||||
</pre>
|
||||
@@ -144,7 +145,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
|
||||
{% if related_changes_count > related_changes_table.rows|length %}
|
||||
@@ -158,4 +167,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load log_levels %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ script }}{% endblock %}
|
||||
{% block title %}{{ script.python_class.name }}{% endblock %}
|
||||
|
||||
{% block object_identifier %}
|
||||
{{ script.full_name }}
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
{% block subtitle %}
|
||||
<div class="text-secondary fs-5">
|
||||
{{ script.Meta.description|markdown }}
|
||||
{{ script.python_class.Meta.description|markdown }}
|
||||
</div>
|
||||
{% endblock subtitle %}
|
||||
|
||||
|
||||
@@ -56,15 +56,15 @@
|
||||
<tr>
|
||||
<td>
|
||||
{% if script.is_executable %}
|
||||
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
|
||||
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
|
||||
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||
<span class="text-danger">
|
||||
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ script.description|markdown|placeholder }}</td>
|
||||
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{# Object table controls #}
|
||||
<div class="row mb-3">
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if request.user.is_authenticated and job.completed %}
|
||||
<div class="table-configure input-group">
|
||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
|
||||
class="btn">
|
||||
|
||||
@@ -48,7 +48,7 @@ Context:
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
|
||||
{% trans "Results" %}
|
||||
<span class="badge text-bg-secondary total-object-count">{{ table.page.paginator.count }}</span>
|
||||
<span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count }}{% endif %}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if filter_form %}
|
||||
|
||||
10
netbox/templates/inc/light_toggle.html
Normal file
10
netbox/templates/inc/light_toggle.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="d-flex">
|
||||
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||
<i class="mdi mdi-lightbulb"></i>
|
||||
</button>
|
||||
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||
<i class="mdi mdi-lightbulb-on"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,25 +1,37 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="row mb-3" id="results">
|
||||
<div class="col-auto d-print-none">
|
||||
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
|
||||
<input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search"
|
||||
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" />
|
||||
<input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
|
||||
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
|
||||
<span class="input-group-text py-1">
|
||||
<a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
|
||||
</span>
|
||||
{% block extra_table_controls %}{% endblock %}
|
||||
{% block extra_table_controls %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-print-none">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
|
||||
</div>
|
||||
{{ filter_form.filter_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
{% if request.user.is_authenticated and table_modal %}
|
||||
<div class="table-configure input-group">
|
||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}"
|
||||
class="btn">
|
||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}"
|
||||
data-bs-target="#{{ table_modal }}"
|
||||
class="btn">
|
||||
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if memory_sum %}
|
||||
{{ memory_sum|humanize_megabytes }}
|
||||
<span title={{ memory_sum }}>{{ memory_sum|humanize_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
{{ object.memory|humanize_megabytes }}
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
@@ -4,8 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.relations import get_related_models
|
||||
from utilities.views import register_model_view, ViewTab
|
||||
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
|
||||
@@ -56,17 +55,14 @@ class TenantGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(TenantGroup)
|
||||
class TenantGroupView(generic.ObjectView):
|
||||
class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = TenantGroup.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
groups = instance.get_descendants(include_self=True)
|
||||
related_models = (
|
||||
(Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, groups),
|
||||
}
|
||||
|
||||
|
||||
@@ -123,17 +119,12 @@ class TenantListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Tenant)
|
||||
class TenantView(generic.ObjectView):
|
||||
class TenantView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Tenant.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = [
|
||||
(model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
|
||||
for model, field in get_related_models(Tenant)
|
||||
]
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@@ -189,17 +180,14 @@ class ContactGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ContactGroup)
|
||||
class ContactGroupView(generic.ObjectView):
|
||||
class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ContactGroup.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
groups = instance.get_descendants(include_self=True)
|
||||
related_models = (
|
||||
(Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, groups),
|
||||
}
|
||||
|
||||
|
||||
@@ -256,16 +244,12 @@ class ContactRoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ContactRole)
|
||||
class ContactRoleView(generic.ObjectView):
|
||||
class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ContactRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ def handle_protectederror(obj_list, request, e):
|
||||
if hasattr(dependent, 'get_absolute_url'):
|
||||
dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
|
||||
else:
|
||||
dependent_objects.append(str(dependent))
|
||||
dependent_objects.append(escape(str(dependent)))
|
||||
err_message += ', '.join(dependent_objects)
|
||||
|
||||
messages.error(request, mark_safe(err_message))
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import serializers
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from extras.utils import is_taggable
|
||||
|
||||
@@ -16,8 +15,7 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
||||
"""
|
||||
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
|
||||
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
|
||||
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
|
||||
implicitly excluded.
|
||||
can be provided to exclude them from the returned dictionary.
|
||||
|
||||
Args:
|
||||
obj: The object to serialize
|
||||
@@ -30,11 +28,6 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
||||
data = json.loads(json_str)[0]['fields']
|
||||
exclude = exclude or []
|
||||
|
||||
# Exclude any MPTTModel fields
|
||||
if issubclass(obj.__class__, MPTTModel):
|
||||
for field in ['level', 'lft', 'rght', 'tree_id']:
|
||||
data.pop(field)
|
||||
|
||||
# Include custom_field_data as "custom_fields"
|
||||
if hasattr(obj, 'custom_field_data'):
|
||||
data['custom_fields'] = data.pop('custom_field_data')
|
||||
@@ -45,9 +38,9 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
||||
tags = getattr(obj, '_tags', None) or obj.tags.all()
|
||||
data['tags'] = sorted([tag.name for tag in tags])
|
||||
|
||||
# Skip excluded and private (prefixes with an underscore) attributes
|
||||
# Skip any excluded attributes
|
||||
for key in list(data.keys()):
|
||||
if key in exclude or (isinstance(key, str) and key.startswith('_')):
|
||||
if key in exclude:
|
||||
data.pop(key)
|
||||
|
||||
# Append any extra data
|
||||
|
||||
@@ -29,7 +29,7 @@ def linkify_phone(value):
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return f"tel:{value}"
|
||||
return f"tel:{value.replace(' ', '')}"
|
||||
|
||||
|
||||
def register_table_column(column, name, *tables):
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% elif customfield.type == 'date' and value %}
|
||||
{{ value|isodate }}
|
||||
{% elif customfield.type == 'datetime' and value %}
|
||||
{{ value|isodate }} {{ value|isodatetime }}
|
||||
{{ value|isodatetime }}
|
||||
{% elif customfield.type == 'url' and value %}
|
||||
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
||||
{% elif customfield.type == 'json' and value %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user