Compare commits

..

92 Commits

Author SHA1 Message Date
Jeremy Stretch
b7f6b728b9 Merge pull request #15222 from netbox-community/develop
Release v3.7.3
2024-02-21 16:01:21 -05:00
Jeremy Stretch
503c78b0db Release v3.7.3 2024-02-21 15:46:41 -05:00
Jeremy Stretch
cb05288c4d Update translations 2024-02-21 15:24:50 -05:00
Jeremy Stretch
0373b8aade Update translation strings 2024-02-21 14:49:09 -05:00
Jeremy Stretch
580d417aa1 Changelog for #14064, #14689, #14966, #15101, #15185 2024-02-21 14:46:10 -05:00
Abhimanyu Saharan
8571f428b1 fixed location import #14064 2024-02-21 14:10:10 -05:00
Jeremy Stretch
276a73f820 #15094: Fix missing format variable 2024-02-21 14:06:01 -05:00
Abhimanyu Saharan
d8fb5a819f fixed json field save issue #14689 2024-02-21 14:00:34 -05:00
Abhimanyu Saharan
f14eac58e4 Fixed error display on parent import form (#15213)
* fixed error display on parent import form #15185

* Rename parent_form; handle errors assigned to __all__

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-21 13:50:09 -05:00
Abhimanyu Saharan
1780acc8a6 Fixes the rackelevation api schema (#15214)
* fixes the rackelevation api schema #15101

* fixes the rackelevation api schema #15101
2024-02-21 13:39:32 -05:00
Abhimanyu Saharan
a3b8262ab0 Added index on cachevalue (#15199)
* added index on cachevalue #14966

* Update netbox/extras/models/search.py

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

* fixed migration

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-21 13:20:55 -05:00
Arthur
a1ee02cdf0 15211 fix typo on DeviceType detail view 2024-02-21 12:10:35 -05:00
Jeremy Stretch
f751afcce7 Changelog for #14405, #14587, #14946, #15090. #15174, #15177, #15184, #15192 2024-02-20 16:29:46 -05:00
Arthur Hanson
17a321a340 14405 render link_peer to CSV (#15201)
* 14405 render link_peer to csv

* 14405 review changes
2024-02-20 16:24:14 -05:00
Zacho
cf3969bc6c Added Last Login to user/profile GUI views and the /users/user API output (#15198)
* Added Last Login to user/profile GUI and user api output

* Update netbox/templates/account/profile.html

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

* Update netbox/templates/account/profile.html

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

* Update netbox/templates/users/user.html

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-20 15:43:49 -05:00
Arthur
9b9afdcf79 15192 fix config revision if no revisions 2024-02-20 14:28:04 -05:00
Abhimanyu Saharan
50e5bb9717 added validation error for script and report constraint #15174 2024-02-20 14:15:27 -05:00
Abhimanyu Saharan
a063b5563c Added oidc to auth list (#15204)
* added oidc to auth list #14587

* Alphabetic ordering

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-20 14:11:50 -05:00
Abhimanyu Saharan
8678d1a577 removed associate_by_email #14946 2024-02-20 14:10:47 -05:00
Abhimanyu Saharan
839609d101 Added allow_null for front and rear image on api (#15200)
* added allow_null for front and rear image on api #15184

* added allow_null for front and rear image on api #15184
2024-02-20 13:53:56 -05:00
Jeremy Stretch
dbcd713fe7 Fixes #15090: Run deletion protection rules prior to enqueueing events 2024-02-20 13:22:55 -05:00
Jeremy Stretch
d216161014 Add link to netbox-docker repo 2024-02-20 11:36:27 -05:00
Jeremy Stretch
056543e1d2 Changelog for #14058, #14079, #14952, #15127 2024-02-20 09:45:58 -05:00
Arthur Hanson
af27bf5eff 15094 Add missing gettext to error strings for internationalization (#15155)
* 15049 add missing gettext to error strings

* 15049 add missing gettext to error strings

* 15094 review change

* 15094 review change

* Formatting cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-20 09:44:02 -05:00
Daniel Sheppard
29f029d480 Fixes: #14058 - Limits platform selection to manufacturer and platforms with no manufacturer (#15183)
* Fixes: #14058 - Limits platform selection to manufacturer and platforms with no manufacturer

* Apply suggestions from code review

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-20 09:28:15 -05:00
Jeremy Stretch
bd7d4a3f34 Fixes #14079: Explicitly remove M2M assignments to objects being deleted to ensure change logging 2024-02-16 11:42:27 -05:00
Jeremy Stretch
de5c5aeb2a Fixes #14952: Update existing AutoSyncRecord when changing the data file of an auto-synced object 2024-02-16 11:38:47 -05:00
Abhimanyu Saharan
2e74952ac6 added missing import #15058 2024-02-16 01:20:54 +05:30
Jeremy Stretch
7cc215437f Fixes #15127: Add missing group column on tunnels table 2024-02-14 09:27:01 -05:00
Jeremy Stretch
e84e2a7969 Changelog for #15059, #15067, #15091, #15115, #15126, #15133 2024-02-13 16:39:41 -05:00
Jeremy Stretch
2d70b50286 Fixes #15059: Correct IP address count link in VM interfaces table 2024-02-13 12:16:11 -05:00
Jeremy Stretch
01fa2710eb Fixes #15067: Fix uncaught exception when attempting invalid device bay import 2024-02-13 12:15:15 -05:00
Jeremy Stretch
12d830bcf2 Fixes #15133: Fix FHRP group representation on assignments endpoint under brief mode (#15134)
* Fixes #15133: Fix FHRP group representation on assignments endpoint under brief mode

* Update API test
2024-02-13 11:29:53 -05:00
Jeremy Stretch
c37dfdc150 Fixes #15091: Fix initial active tab when editing an L2VPN termination 2024-02-13 11:27:50 -05:00
Jeremy Stretch
df910928f2 Fixes #15126: group field should be optional when creating VPN tunnel via REST API 2024-02-13 09:55:33 -05:00
Jeremy Stretch
1f800a975f Fixes #15115: Fix unhandled exception with invalid permission constraints 2024-02-13 09:55:07 -05:00
teapot
c7ae2db8e3 Fixes #15111: Correct typo in error message 2024-02-12 08:44:22 -05:00
Ikko Eltociear Ashimine
ae7d6ffd92 Update remote-authentication.md
Seperator -> Separator
2024-02-12 08:43:11 -05:00
Jeff Gehlbach
011bc5bd78 Merge pull request #15053 from aharrisson/develop
Fix custom script documentation example script
2024-02-09 11:19:51 -05:00
Jeremy Stretch
040dbcc875 Fixes #15070: Fix inclusion of config_template field on REST API serializer for virtual machines 2024-02-08 09:10:24 -05:00
Jeremy Stretch
64b2ebdc79 Fixes #15084: Fix "add export template" link 2024-02-08 08:47:16 -05:00
Anders Harrisson
4afebd3565 Fix custom script documentation example script
The example script still uses the old "role" field when creating
a Device object.

Fixes #15052
2024-02-06 12:42:17 +01:00
Jeremy Stretch
28aee9b69a PRVB 2024-02-05 14:12:50 -05:00
Jeremy Stretch
426805cd24 Merge pull request #15048 from netbox-community/develop
Release v3.7.2
2024-02-05 14:10:13 -05:00
Jeremy Stretch
a331ba65cb Release v3.7.2 2024-02-05 13:56:52 -05:00
Smixi
4ba0ec78cf fix: performance for get__available_ips for prefix (#15041) 2024-02-05 13:30:59 -05:00
Jeremy Stretch
93edf74f7c Fixes #14945: Fix "select all" button for device type components (#15027) 2024-02-05 13:05:25 -05:00
Jeremy Stretch
8a77ec70f2 Fixes #15015: Pre-populate assigned tenant when allocating next available IP address under prefix view 2024-02-05 12:59:50 -05:00
Daniel Sheppard
0eba3acdb8 Closes: #14570 - Remove extra query for job under scripts and reports detailed view (#14998)
* Closes: #14570 - Remove extra query for job under scripts and reports detailed view

* Add report.result back as it is used by report.html
2024-02-05 12:13:03 -05:00
Daniel Sheppard
32083e58c0 Fixes: #14840 - Forces API to use django user model instead of proxy model (#14881)
* Fixes: #14840 - Forces API to use proxy model

* Update tests to use proxy model

* Revert "Update tests to use proxy model"

This reverts commit 1d784cfe5d.

* Revert "Fixes: #14840 - Forces API to use proxy model"

This reverts commit df85cc967c.

* More realistic change to resole issue with netboxusers-list

* Revert "More realistic change to resole issue with netboxusers-list"

This reverts commit 15df8082aa.

* Fixes: #14840 - Better fix for netboxusers-list

* Swap model for serializer from proxy model
2024-02-05 11:57:30 -05:00
Arthur Hanson
8e8d302850 15020 Update assigned VMs site when move cluster (#15031)
* 15020 Update assigned VMs site when move cluster

* 15020 call super

* 15020 change to use denormalized
2024-02-05 11:41:33 -05:00
Jeremy Stretch
fde9c1664a Closes #13729: Censor sensitive data source parameters in change log (#15032) 2024-02-05 11:35:12 -05:00
Daniel Sheppard
1a9149d7d4 Fixes: #14839 - Check for tunnel termination type in data and instance in addition to intially passed data. (#14995)
* Fixes: #14839 - Check for tunnel termination type in additional instances

* Incorporate recommended changes
2024-02-05 09:59:24 -05:00
Arthur Hanson
31fb6961e9 14947 fix for missing changelog if only update m2m (#14986)
* 14947 fix for missing changelog if only update m2m

* 14947 review change

* 14947 DRY save logic

* 14947 DRY save logic

* Refactor logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-05 09:52:10 -05:00
Jeremy Stretch
b408beaed5 Changelog for #14962, #14999, #15025 2024-02-02 16:36:35 -05:00
Arthur
1b6fc49a3e 14999 fix FHRP create and add another 2024-02-02 14:52:38 -05:00
Jeremy Stretch
9f25289ce2 Fixes #15025: can_add() template filter should accept a model (not an instance) 2024-02-02 11:59:44 -05:00
ChrisPortman
59510b4bd0 Issue #14962 VM to merge directly related site context (#14992)
* Issue #14962 VM to merge directly related site context

* Cleanup & rewrite test

---------

Co-authored-by: Chris Carter <chris.carter@spinlocksecurity.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-02 10:16:07 -05:00
teapot
1b9e6bed55 Fixes #14960: Correct typo in label 2024-01-29 08:48:02 -05:00
Mattias L
ba755221bb Improved docs for how to register dashboard widgets (#14913) 2024-01-26 14:15:28 -05:00
Abhimanyu Saharan
b9cac97b73 remove GIT_PATH #14942 2024-01-25 17:26:32 -05:00
Abhimanyu Saharan
3dc43861c5 fixed typo in cluster bulk edit #14936 2024-01-25 17:24:58 -05:00
Jeremy Stretch
b9b4b8ae62 Add warning concerning AI-generated content 2024-01-25 13:58:23 -05:00
Jeremy Stretch
98c9f7fbbd Changelog for #14511, #14703, #14838, #14920 2024-01-25 12:55:22 -05:00
teapot
441e24bca7 Fixes #14934: Correct typo in label 2024-01-25 12:20:21 -05:00
Arthur
c8fb948a91 14511 Fix connected endpoints for GraphQL 2024-01-25 08:47:32 -05:00
Arthur Hanson
a141f7f771 14691 add documentation for gunicorn bug (#14924)
* 14691 add documentation for gunicorn bug

* Update docs/installation/4-gunicorn.md

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-25 08:41:01 -05:00
Arthur
f26ac3e7cb 14920 add help to status field in virtual device context import 2024-01-25 08:31:40 -05:00
Jeremy Stretch
487f1ccfde Fixes #14703: Catch exceptions when rendering dashboard and revert to default 2024-01-24 17:19:07 -05:00
Jeremy Stretch
481d16de08 Fixes #14838: JSONField should treat initial form data as JSON 2024-01-24 17:18:17 -05:00
Jeremy Stretch
23e201cec6 Fixes #14905: Fix miscellaneous errors with string translations 2024-01-24 17:05:02 -05:00
Jeremy Stretch
fea8efa149 Closes #14611, #14808: Add Japanese & Turkish translations 2024-01-23 12:48:15 -05:00
Jeremy Stretch
0df7ca4309 Update translation sources 2024-01-22 16:18:20 -05:00
Jeremy Stretch
e4188b5bde Changelog for #14572, #14847, #14879, #14885, #14892 2024-01-22 16:00:56 -05:00
Jeremy Stretch
cd8e977418 Fixes #14879: Include custom fields in REST API representation of data sources 2024-01-22 15:54:26 -05:00
Jeremy Stretch
88e4559b5a Fixes #14885: Add missing 'group' field to tunnel creation form 2024-01-22 15:53:29 -05:00
Jeremy Stretch
d606749335 Fixes #14892: Omit username when running report/script via command line 2024-01-22 15:52:03 -05:00
Jeremy Stretch
ff752dac07 Closes #14862: Add note to date & time configs regarding localization 2024-01-22 15:12:01 -05:00
Jeremy Stretch
3aaf370d4a Closes #14889: Update source path for DataBackend class 2024-01-22 14:54:02 -05:00
Daniel Sheppard
fd5392563f Fixes #14572 - Constrains JobView (and related views) badge to specific named job (#14754)
* Fixes #14572 - Constrains JobView (and related views) badge to specific named job

* Adjust report views to resolve same problem

* Fixed PEP8 error

* Update netbox/templates/extras/script/base.html

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

* Move function to method on PythonModuleMixin

* Update netbox/extras/views.py

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

* Update netbox/extras/views.py

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

* Update netbox/extras/views.py

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

* Update netbox/extras/views.py

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

* Update to mixin and view

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-22 14:01:53 -05:00
Martin
79e0d3ae67 Fixes #14847: Relax requirement for IKE policy (#14878)
* Fixes #14847: Relax requirement for IKE policy

* Docs tweak

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-22 13:27:55 -05:00
Jeremy Stretch
1d15ba56b9 Grant permission to modify discussions 2024-01-20 23:02:55 -05:00
Jeremy Stretch
2b4ec9dc20 Update migration file for dummy plugin 2024-01-19 16:12:50 -05:00
Jeremy Stretch
1651a307c8 #14872: Permit makemigrations --check without setting DEVELOPER=True 2024-01-19 16:08:58 -05:00
Jeremy Stretch
93a05289ad Closes #14872: Extend CI workflow to check for missing Django migrations 2024-01-19 15:59:06 -05:00
Jeremy Stretch
04575aa0f8 Automatically lock inactive GitHub discussions after 180 days 2024-01-19 15:51:22 -05:00
Jeremy Stretch
d5733a1e89 Changelog for #14645, #14755, #14851 2024-01-19 15:46:38 -05:00
Julio Oliveira at Encora
48168de4ff Fixes #14755: ValueError in web UI after REST API accepts invalid cus… (#14804)
* Fixes #14755: ValueError in web UI after REST API accepts invalid custom-field choice-set data

* PR Comments Addressed

* Set max_length=2 on extra_choices items; remove custom validation logic

* Move test for invalid choices to CustomFieldChoiceSetTest

* Omit unused imports

---------

Co-authored-by: julio.oliveira <julio.oliveira@alertmedia.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-19 15:24:08 -05:00
Abhimanyu Saharan
a87d76ad17 Fixes user delete when they have a bookmark (#14867)
* fixes user delete when they have a bookmark #14851

* Include migration for user field

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-19 15:23:20 -05:00
Abhimanyu Saharan
749fc31bc4 limits ip addresses on interface tables #14645 2024-01-19 14:53:24 -05:00
Jeremy Stretch
ebf6ce1b01 PRVB 2024-01-17 15:02:23 -05:00
Jeremy Stretch
b871a6c7a6 Merge pull request #14830 from netbox-community/develop
Release v3.7.1
2024-01-17 15:00:06 -05:00
144 changed files with 37340 additions and 5004 deletions

View File

@@ -13,7 +13,9 @@ body:
- type: dropdown
attributes:
label: Deployment Type
description: How are you running NetBox?
description: >
How are you running NetBox? (For issues with the Docker image, please go to the
[netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
options:
- Self-hosted
- NetBox Cloud
@@ -23,7 +25,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v3.7.1
placeholder: v3.7.3
validations:
required: true
- type: dropdown

View File

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

View File

@@ -68,6 +68,9 @@ jobs:
- name: Collect static files
run: python netbox/manage.py collectstatic --no-input
- name: Check for missing migrations
run: python netbox/manage.py makemigrations --check
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/

View File

@@ -9,13 +9,15 @@ on:
permissions:
issues: write
pull-requests: write
discussions: write
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: 90
pr-inactive-days: 30
discussion-inactive-days: 180
issue-lock-reason: 'resolved'

View File

@@ -86,12 +86,16 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
* All code submissions should meet the following criteria (CI will enforce these checks):
* All code submissions must meet the following criteria (CI will enforce these checks where feasible):
* Consist entirely of original work
* Python syntax is valid
* All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be
greater than 80 characters in length
> [!CAUTION]
> Any contributions which include AI-generated or reproduced content will be rejected.
* Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
@@ -117,8 +121,6 @@ We're always looking for motivated individuals to join the maintainers team and
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
Many maintainers petition their employer to grant some of their paid time to work on NetBox. In doing so, your employer becomes eligible to be featured as a [NetBox sponsor](https://github.com/netbox-community/netbox/wiki/Sponsorship).
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
## :heart: Other Ways to Contribute

View File

@@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-4-blue" alt="Languages supported" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p>
</div>

View File

@@ -105,7 +105,7 @@ mkdocs-material
mkdocstrings[python-legacy]
# Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
netaddr
# Fork of PIL (Python Imaging Library) for image processing

View File

@@ -10,6 +10,9 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below.
!!! note
These system defaults will be overridden by a user's selected language/locale when [localization](./system.md#enable_localization) is enabled.
```python
DATE_FORMAT = 'N j, Y' # June 26, 2016
SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26

View File

@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---

View File

@@ -69,15 +69,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
Default: False
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
---
## GIT_PATH
Default: `git`
The system path to the `git` executable, used by the synchronization backend for remote git repositories.
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding any configured [system defaults](./date-time.md#date-and-time-formatting)) based on the browser locale as well as translate certain strings from third party modules.
---

View File

@@ -390,7 +390,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}',
site=site,
status=DeviceStatusChoices.STATUS_PLANNED,
role=switch_role
device_role=switch_role
)
switch.full_clean()
switch.save()

View File

@@ -58,3 +58,6 @@ You should see output similar to the following:
If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
!!! note
There is a bug in the current stable release of gunicorn (v21.2.0) where automatic restarts of the worker processes can result in 502 errors under heavy load. (See [gunicorn bug #3038](https://github.com/benoitc/gunicorn/issues/3038) for more detail.) Users who encounter this issue may opt to downgrade to an earlier, unaffected release of gunicorn (`pip install gunicorn==20.1.0`). Note, however, that this earlier release does not officially support Python 3.11.

View File

@@ -14,7 +14,7 @@ The IKE version employed (v1 or v2).
### Mode
The IKE mode employed (main or aggressive).
The mode employed (main or aggressive) when IKEv1 is in use. This setting is not supported for IKEv2.
### Proposals

View File

@@ -47,3 +47,14 @@ class ReminderWidget(DashboardWidget):
def render(self, request):
return self.config.get('content')
```
## Initialization
To register the widget, it becomes essential to import the widget module. The recommended approach is to accomplish this within the `ready` method situated in your `PluginConfig`:
```python
class FooBarConfig(PluginConfig):
def ready(self):
super().ready()
from . import widgets # point this to the above widget module you created
```

View File

@@ -20,4 +20,4 @@ backends = [MyDataBackend]
!!! tip
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
::: core.data_backends.DataBackend
::: netbox.data_backends.DataBackend

View File

@@ -1,5 +1,73 @@
# NetBox v3.7
## v3.7.3 (2024-02-21)
### Enhancements
* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
### Bug Fixes
* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
---
## v3.7.2 (2024-02-05)
### Enhancements
* [#13729](https://github.com/netbox-community/netbox/issues/13729) - Omit sensitive data source parameters from change log data
* [#14645](https://github.com/netbox-community/netbox/issues/14645) - Limit the number of assigned IP addresses displayed under interfaces list
### Bug Fixes
* [#14500](https://github.com/netbox-community/netbox/issues/14500) - Optimize calculation of available child prefixes & ranges when viewing a prefix
* [#14511](https://github.com/netbox-community/netbox/issues/14511) - Fix GraphQL support for interfaces connected to provider networks
* [#14572](https://github.com/netbox-community/netbox/issues/14572) - Correct the number of jobs listed for individual report & script modules
* [#14703](https://github.com/netbox-community/netbox/issues/14703) - Revert to the default layout when encountering a misconfigured dashboard
* [#14755](https://github.com/netbox-community/netbox/issues/14755) - Fix validation of choice values & labels when creating a custom field choice set via the REST API
* [#14838](https://github.com/netbox-community/netbox/issues/14838) - Avoid corrupting JSON data when changing the action type while editing an event rule
* [#14839](https://github.com/netbox-community/netbox/issues/14839) - Fix form validation error when attempting to terminate a tunnel to a virtual machine interface
* [#14840](https://github.com/netbox-community/netbox/issues/14840) - Fix `NoReverseMatch` exception when rendering a custom field which references a user
* [#14847](https://github.com/netbox-community/netbox/issues/14847) - IKE policy mode may be set inly when IKEv1 is selected
* [#14851](https://github.com/netbox-community/netbox/issues/14851) - Automatically remove any associated bookmarks when deleting a user
* [#14879](https://github.com/netbox-community/netbox/issues/14879) - Include custom fields in REST API representation of data sources
* [#14885](https://github.com/netbox-community/netbox/issues/14885) - Add missing "group" field to VPN tunnel creation form
* [#14892](https://github.com/netbox-community/netbox/issues/14892) - Fix exception when running report/script via command line due to missing username
* [#14920](https://github.com/netbox-community/netbox/issues/14920) - Include button to display available status choices when bulk importing virtual device contexts
* [#14945](https://github.com/netbox-community/netbox/issues/14945) - Fix "select all" button for device type components
* [#14947](https://github.com/netbox-community/netbox/issues/14947) - Ensure that application & removal of tags is always recorded in an object's change log
* [#14962](https://github.com/netbox-community/netbox/issues/14962) - Fix config context rendering for VMs assigned directly to a site (rather than via a cluster)
* [#14999](https://github.com/netbox-community/netbox/issues/14999) - Fix "create & add another" link for interface FHRP group assignment
* [#15015](https://github.com/netbox-community/netbox/issues/15015) - Pre-populate assigned tenant when allocating next available IP address under prefix view
* [#15020](https://github.com/netbox-community/netbox/issues/15020) - Automatically update all VMs when changing a cluster's assigned site
* [#15025](https://github.com/netbox-community/netbox/issues/15025) - The `can_add()` template filter should accept a model (not an instance)
---
## v3.7.1 (2024-01-17)
### Bug Fixes

View File

@@ -234,9 +234,9 @@ class CircuitTermination(
# Must define either site *or* provider network
if self.site is None and self.provider_network is None:
raise ValidationError("A circuit termination must attach to either a site or a provider network.")
raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
if self.site and self.provider_network:
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)

View File

@@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField

View File

@@ -36,7 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
model = DataSource
fields = [
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
]

View File

@@ -102,7 +102,7 @@ class GitBackend(DataBackend):
try:
porcelain.clone(self.url, local_path.name, **clone_args)
except BaseException as e:
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
yield local_path.name

View File

@@ -21,7 +21,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Enforce unique space')
label=_('Enabled')
)
description = forms.CharField(
label=_('Description'),

View File

@@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
super().clean()
if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
raise forms.ValidationError("Cannot upload a file and sync from an existing file")
raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
raise forms.ValidationError("Must upload a file or select a data file to sync")
raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
return self.cleaned_data

View File

@@ -9,9 +9,9 @@ class Command(_Command):
"""
This built-in management command enables the creation of new database schema migration files, which should
never be required by and ordinary user. We prevent this command from executing unless the configuration
indicates that the user is a developer (i.e. configuration.DEVELOPER == True).
indicates that the user is a developer (i.e. configuration.DEVELOPER == True), or it was run with --check.
"""
if not settings.DEVELOPER:
if not kwargs['check_changes'] and not settings.DEVELOPER:
raise CommandError(
"This command is available for development purposes only. It will\n"
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"

View File

@@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
return gettext('Config revision #{id}').format(id=self.pk)
def __getattr__(self, item):
if item in self.data:
if self.data and item in self.data:
return self.data[item]
return super().__getattribute__(item)

View File

@@ -14,6 +14,7 @@ from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel
from netbox.models.features import JobsMixin
from netbox.registry import registry
@@ -130,6 +131,28 @@ class DataSource(JobsMixin, PrimaryModel):
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
})
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
# Censor any backend parameters marked as sensitive in the serialized data
pre_change_params = {}
post_change_params = {}
if objectchange.prechange_data:
pre_change_params = objectchange.prechange_data.get('parameters') or {} # parameters may be None
if objectchange.postchange_data:
post_change_params = objectchange.postchange_data.get('parameters') or {}
for param in self.backend_class.sensitive_parameters:
if post_change_params.get(param):
if post_change_params[param] != pre_change_params.get(param):
# Set the "changed" token if the parameter's value has been modified
post_change_params[param] = CENSOR_TOKEN_CHANGED
else:
post_change_params[param] = CENSOR_TOKEN
if pre_change_params.get(param):
pre_change_params[param] = CENSOR_TOKEN
return objectchange
def enqueue_sync_job(self, request):
"""
Enqueue a background job to synchronize the DataSource by calling sync().
@@ -154,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
"""
if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError("Cannot initiate sync; syncing already in progress.")
raise SyncError(_("Cannot initiate sync; syncing already in progress."))
# Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self)
@@ -167,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
backend = self.get_backend()
except ModuleNotFoundError as e:
raise SyncError(
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
_("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
)
with backend.fetch() as local_path:

View File

@@ -181,7 +181,11 @@ class Job(models.Model):
"""
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses:
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
raise ValueError(
_("Invalid status for job termination. Choices are: {choices}").format(
choices=', '.join(valid_statuses)
)
)
# Mark the job as completed
self.status = status

View File

@@ -0,0 +1,122 @@
from django.test import TestCase
from core.models import DataSource
from extras.choices import ObjectChangeActionChoices
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'foobar123',
}
)
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_CREATE)
self.assertIsNone(objectchange.prechange_data)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_added_on_update(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/'
)
datasource.snapshot()
# Add a blank password
datasource.parameters = {
'username': 'jeff',
'password': '',
}
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertIsNone(objectchange.prechange_data['parameters'])
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
# Add a password
datasource.parameters = {
'username': 'jeff',
'password': 'foobar123',
}
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_changed(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'password1',
}
)
datasource.snapshot()
# Change the password
datasource.parameters['password'] = 'password2'
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
def test_password_removed_on_update(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'jeff',
'password': 'foobar123',
}
)
datasource.snapshot()
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
# Remove the password
datasource.parameters['password'] = ''
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
def test_password_not_modified(self):
datasource = DataSource.objects.create(
name='Data Source 1',
type='git',
source_url='http://localhost/',
parameters={
'username': 'username1',
'password': 'foobar123',
}
)
datasource.snapshot()
# Remove the password
datasource.parameters['username'] = 'username2'
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'username1')
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)

View File

@@ -166,7 +166,7 @@ class ConfigView(generic.ObjectView):
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
data=get_config()
data=get_config().defaults
)

View File

@@ -326,6 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
front_image = serializers.URLField(allow_null=True, required=False)
rear_image = serializers.URLField(allow_null=True, required=False)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)

View File

@@ -191,6 +191,12 @@ class RackViewSet(NetBoxModelViewSet):
serializer_class = serializers.RackSerializer
filterset_class = filtersets.RackFilterSet
@extend_schema(
operation_id='dcim_racks_elevation_retrieve',
filters=False,
parameters=[serializers.RackElevationDetailFilterSerializer],
responses={200: serializers.RackUnitSerializer(many=True)}
)
@action(detail=True)
def elevation(self, request, pk=None):
"""

View File

@@ -1,6 +1,7 @@
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext as _
from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError:
raise ValidationError(f"Invalid MAC address format: {value}")
raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
def db_type(self, connection):
return 'macaddr'
@@ -67,7 +68,7 @@ class WWNField(models.Field):
try:
return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
except AddrFormatError:
raise ValidationError(f"Invalid WWN format: {value}")
raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
def db_type(self, connection):
return 'macaddr8'

View File

@@ -2,6 +2,8 @@ import django_filters
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from circuits.models import CircuitTermination
from extras.filtersets import LocalConfigContextFilterSet
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
available_for_device_type = django_filters.ModelChoiceFilter(
queryset=DeviceType.objects.all(),
method='get_for_device_type'
)
config_template_id = django_filters.ModelMultipleChoiceFilter(
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
model = Platform
fields = ['id', 'name', 'slug', 'description']
@extend_schema_field(OpenApiTypes.STR)
def get_for_device_type(self, queryset, name, value):
"""
Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
manufacturer
"""
return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
class DeviceFilterSet(
NetBoxModelFilterSet,

View File

@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
model = Location
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
class RackRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
@@ -727,7 +735,7 @@ class PowerOutletImportForm(NetBoxModelImportForm):
help_text=_('Local power port which feeds this outlet')
)
feed_leg = CSVChoiceField(
label=_('Feed lag'),
label=_('Feed leg'),
choices=PowerOutletFeedLegChoices,
required=False,
help_text=_('Electrical phase (for three-phase circuits)')
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']:
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
raise forms.ValidationError(
_("VDC {vdc} is not assigned to device {device}").format(
vdc=vdc, device=self.cleaned_data['device']
)
)
return self.cleaned_data['vdcs']
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk)
else:
self.fields['installed_device'].queryset = Interface.objects.none()
self.fields['installed_device'].queryset = Device.objects.none()
class InventoryItemImportForm(NetBoxModelImportForm):
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
)
)
#
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
raise forms.ValidationError(
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
)
)
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
@@ -1359,6 +1382,10 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text='Assigned tenant'
)
status = CSVChoiceField(
label=_('Status'),
choices=VirtualDeviceContextStatusChoices,
)
class Meta:
fields = [

View File

@@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
required=False
required=False,
selector=True,
query_params={
'manufacturer_id': ['$manufacturer', 'null'],
}
)
slug = SlugField(
label=_('Slug'),
@@ -444,7 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Platform'),
queryset=Platform.objects.all(),
required=False,
selector=True
selector=True,
query_params={
'available_for_device_type': '$device_type',
}
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),

View File

@@ -1,6 +1,6 @@
import graphene
from circuits.graphql.types import CircuitTerminationType
from circuits.models import CircuitTermination
from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
from circuits.models import CircuitTermination, ProviderNetwork
from dcim.graphql.types import (
ConsolePortTemplateType,
ConsolePortType,
@@ -167,3 +167,42 @@ class InventoryItemComponentType(graphene.Union):
return PowerPortType
if type(instance) is RearPort:
return RearPortType
class ConnectedEndpointType(graphene.Union):
class Meta:
types = (
CircuitTerminationType,
ConsolePortType,
ConsoleServerPortType,
FrontPortType,
InterfaceType,
PowerFeedType,
PowerOutletType,
PowerPortType,
ProviderNetworkType,
RearPortType,
)
@classmethod
def resolve_type(cls, instance, info):
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) is FrontPort:
return FrontPortType
if type(instance) is Interface:
return InterfaceType
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) is PowerPort:
return PowerPortType
if type(instance) is ProviderNetwork:
return ProviderNetworkType
if type(instance) is RearPort:
return RearPortType

View File

@@ -13,7 +13,7 @@ class CabledObjectMixin:
class PathEndpointMixin:
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.ConnectedEndpointType')
def resolve_connected_endpoints(self, info):
# Handle empty values

View File

@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
raise ValidationError(_("Must specify a unit when setting a cable length"))
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
if self._terminations_modified:
# Check that all termination objects for either end are of the same type
for terms in (self.a_terminations, self.b_terminations):
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
raise ValidationError("Cannot connect different termination types to same end of cable.")
raise ValidationError(_("Cannot connect different termination types to same end of cable."))
# Check that termination types are compatible
if self.a_terminations and self.b_terminations:
a_type = self.a_terminations[0]._meta.model_name
b_type = self.b_terminations[0]._meta.model_name
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
raise ValidationError(
_("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
)
if a_type == b_type:
# can't directly use self.a_terminations here as possible they
# don't have pk yet
@@ -323,17 +324,24 @@ class CableTermination(ChangeLoggedModel):
).first()
if existing_termination is not None:
raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
f"{self.termination_id}: cable {existing_termination.cable.pk}"
_("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
app_label=self.termination_type.app_label,
model=self.termination_type.model,
termination_id=self.termination_id,
cable_pk=existing_termination.cable.pk
))
)
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
raise ValidationError(
_("Cables cannot be terminated to {type_display} interfaces").format(
type_display=self.termination.get_type_display()
)
)
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
def save(self, *args, **kwargs):

View File

@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
super().clean()
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:
if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type
))
# Cannot install a device into itself, obviously
if self.device == self.installed_device:
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere

View File

@@ -875,7 +875,7 @@ class Device(
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': _(
"A U0 device type ({device_type}) cannot be assigned to a rack position."
"A 0U device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type)
})

View File

@@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'),
)
def value_link_peer(self, value):
return ', '.join([
f"{termination.parent_object} > {termination}" for termination in value
])
class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn(

View File

@@ -36,13 +36,17 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
<div class="table-badge-group">
{% for ip in value.all %}
{% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
{% else %}
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
{% endif %}
{% endfor %}
{% if value.count >= 3 %}
<a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
{% else %}
{% for ip in value.all %}
{% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
{% else %}
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
{% endif %}
{% endfor %}
{% endif %}
</div>
"""

View File

@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status
from dcim.choices import *
@@ -45,7 +46,7 @@ class Mixins:
name='Peer Device'
)
if self.peer_termination_type is None:
raise NotImplementedError("Test case must set peer_termination_type")
raise NotImplementedError(_("Test case must set peer_termination_type"))
peer_obj = self.peer_termination_type.objects.create(
device=peer_device,
name='Peer Termination'

View File

@@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
Platform(name='Platform 4', slug='platform-4'),
)
Platform.objects.bulk_create(platforms)
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_available_for_device_type(self):
manufacturers = Manufacturer.objects.all()[:2]
device_type = DeviceType.objects.create(
manufacturer=manufacturers[0],
model='Device Type 1',
slug='device-type-1',
u_height=1
)
params = {'available_for_device_type': device_type.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Device.objects.all()

View File

@@ -58,7 +58,11 @@ class DeviceComponentsView(generic.ObjectChildrenView):
return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
class DeviceTypeComponentsView(DeviceComponentsView):
class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from rest_framework.fields import Field
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:

View File

@@ -1,8 +1,10 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.fields import ListField
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
from core.api.serializers import JobSerializer
@@ -149,7 +151,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
def validate_type(self, value):
if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
return value
@@ -175,6 +177,12 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
extra_choices = serializers.ListField(
child=serializers.ListField(
min_length=2,
max_length=2
)
)
class Meta:
model = CustomFieldChoiceSet
@@ -538,12 +546,12 @@ class ReportInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value
def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this report.")
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value
@@ -588,12 +596,12 @@ class ScriptInputSerializer(serializers.Serializer):
def validate_schedule_at(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value
def validate_interval(self, value):
if value and not self.context['script'].scheduling_enabled:
raise serializers.ValidationError("Scheduling is not enabled for this script.")
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
return value

View File

@@ -1,5 +1,6 @@
import functools
import re
from django.utils.translation import gettext as _
__all__ = (
'Condition',
@@ -50,11 +51,13 @@ class Condition:
def __init__(self, attr, value, op=EQ, negate=False):
if op not in self.OPERATORS:
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
op=op, operators=', '.join(self.OPERATORS)
))
if type(value) not in self.TYPES:
raise ValueError(f"Unsupported value type: {type(value)}")
raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
if op not in self.TYPES[type(value)]:
raise ValueError(f"Invalid type for {op} operation: {type(value)}")
raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
self.attr = attr
self.value = value
@@ -131,14 +134,17 @@ class ConditionSet:
"""
def __init__(self, ruleset):
if type(ruleset) is not dict:
raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
if len(ruleset) != 1:
raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
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(f"Invalid logic type: {logic} (must be '{AND}' or '{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()
# Compile the set of Conditions

View File

@@ -2,6 +2,7 @@ import uuid
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from netbox.registry import registry
from extras.constants import DEFAULT_DASHBOARD
@@ -32,7 +33,7 @@ def get_widget_class(name):
try:
return registry['widgets'][name]
except KeyError:
raise ValueError(f"Unregistered widget class: {name}")
raise ValueError(_("Unregistered widget class: {name}").format(name=name))
def get_dashboard(user):
@@ -53,13 +54,13 @@ def get_dashboard(user):
return dashboard
def get_default_dashboard():
def get_default_dashboard(config=None):
from extras.models import Dashboard
dashboard = Dashboard()
default_config = settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
config = config or settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
for widget in default_config:
for widget in config:
id = str(uuid.uuid4())
dashboard.layout.append({
'id': id,

View File

@@ -112,7 +112,9 @@ class DashboardWidget:
Params:
request: The current request
"""
raise NotImplementedError(f"{self.__class__} must define a render() method.")
raise NotImplementedError(_("{class_name} must define a render() method.").format(
class_name=self.__class__
))
@property
def name(self):
@@ -178,7 +180,7 @@ class ObjectCountsWidget(DashboardWidget):
try:
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
return data
def render(self, request):
@@ -232,7 +234,7 @@ class ObjectListWidget(DashboardWidget):
try:
urlencode(data)
except (TypeError, ValueError):
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data
def render(self, request):

View File

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django_rq import get_queue
from core.models import Job
@@ -71,10 +72,10 @@ def enqueue_object(queue, instance, user, request_id, action):
})
def process_event_rules(event_rules, model_name, event, data, username, snapshots=None, request_id=None):
try:
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
if username:
user = get_user_model().objects.get(username=username)
except ObjectDoesNotExist:
else:
user = None
for event_rule in event_rules:
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username, snapshot
)
else:
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
action_type=event_rule.action_type
))
def process_event_queue(events):
@@ -175,4 +178,4 @@ def flush_events(queue):
func = import_string(name)
func(queue)
except Exception as e:
logger.error(f"Cannot import events pipeline {name} error: {e}")
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

View File

@@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
try:
webhook = Webhook.objects.get(name=action_object)
except Webhook.DoesNotExist:
raise forms.ValidationError(f"Webhook {action_object} not found")
raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
self.instance.action_object = webhook
# Script
elif action_type == EventRuleActionChoices.SCRIPT:
@@ -211,7 +211,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
try:
module, script = get_module_and_script(module_name, script_name)
except ObjectDoesNotExist:
raise forms.ValidationError(f"Script {action_object} not found")
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = module
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
self.instance.action_parameters = {

View File

@@ -142,10 +142,12 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
}
help_texts = {
'link_text': _(
"Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. Links "
"Jinja2 template code for the link text. Reference the object as {example}. Links "
"which render as empty text will not be displayed."
),
'link_url': _("Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>."),
).format(example="<code>{{ object }}</code>"),
'link_url': _(
"Jinja2 template code for the link URL. Reference the object as {example}."
).format(example="<code>{{ object }}</code>"),
}

View File

@@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext as _
from netbox.registry import registry
from netbox.search.backends import search_backend
@@ -62,7 +63,7 @@ class Command(BaseCommand):
# Determine which models to reindex
indexers = self._get_indexers(*model_labels)
if not indexers:
raise CommandError("No indexers found!")
raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy)

View File

@@ -0,0 +1,21 @@
# Generated by Django 4.2.9 on 2024-01-19 19:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('extras', '0105_customfield_min_max_values'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-02-20 17:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0106_bookmark_user_cascade_deletion'),
]
operations = [
migrations.AddIndex(
model_name='cachedvalue',
index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
),
]

View File

@@ -8,6 +8,16 @@ __all__ = (
class PythonModuleMixin:
def get_jobs(self, name):
"""
Returns a list of Jobs associated with this specific script or report module
:param name: The class name of the script or report
:return: List of Jobs associated with this
"""
return self.jobs.filter(
name=name
)
@property
def path(self):
return os.path.splitext(self.file_path)[0]

View File

@@ -771,7 +771,7 @@ class Bookmark(models.Model):
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
on_delete=models.CASCADE
)
objects = RestrictedQuerySet.as_manager()

View File

@@ -57,6 +57,9 @@ class CachedValue(models.Model):
ordering = ('weight', 'object_type', 'value', 'object_id')
verbose_name = _('cached value')
verbose_name_plural = _('cached values')
indexes = (
models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
)
def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'

View File

@@ -120,34 +120,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
if self.model._meta.model_name == 'device':
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
region_field = 'site__region'
sitegroup_field = 'site__group'
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
base_query.add(Q(device_types=None), Q.AND)
region_field = 'cluster__site__region'
sitegroup_field = 'cluster__site__group'
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
base_query.add(
(Q(
regions__tree_id=OuterRef(f'{region_field}__tree_id'),
regions__level__lte=OuterRef(f'{region_field}__level'),
regions__lft__lte=OuterRef(f'{region_field}__lft'),
regions__rght__gte=OuterRef(f'{region_field}__rght'),
regions__tree_id=OuterRef('site__region__tree_id'),
regions__level__lte=OuterRef('site__region__level'),
regions__lft__lte=OuterRef('site__region__lft'),
regions__rght__gte=OuterRef('site__region__rght'),
) | Q(regions=None)),
Q.AND
)
base_query.add(
(Q(
site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'),
site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'),
site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'),
site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'),
site_groups__tree_id=OuterRef('site__group__tree_id'),
site_groups__level__lte=OuterRef('site__group__level'),
site_groups__lft__lte=OuterRef('site__group__lft'),
site_groups__rght__gte=OuterRef('site__group__rght'),
) | Q(site_groups=None)),
Q.AND
)

View File

@@ -11,6 +11,7 @@ from django.conf import settings
from django.core.validators import RegexValidator
from django.db import transaction
from django.utils.functional import classproperty
from django.utils.translation import gettext as _
from core.choices import JobStatusChoices
from core.models import Job
@@ -356,7 +357,7 @@ class BaseScript:
return ordered_vars
def run(self, data, commit):
raise NotImplementedError("The script must define a run() method.")
raise NotImplementedError(_("The script must define a run() method."))
# Form rendering
@@ -367,11 +368,11 @@ class BaseScript:
fieldsets.extend(self.fieldsets)
else:
fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields))
fieldsets.append((_('Script Data'), fields))
# Append the default fieldset if defined in the Meta class
exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
fieldsets.append(('Script Execution Parameters', exec_parameters))
fieldsets.append((_('Script Execution Parameters'), exec_parameters))
return fieldsets

View File

@@ -1,8 +1,8 @@
import importlib
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
from extras.models import EventRule
from extras.validators import CustomValidator
from extras.validators import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
@@ -68,21 +69,23 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
# Record an ObjectChange if applicable
if m2m_changed:
ObjectChange.objects.filter(
# Create/update an ObjectChange record for this change
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
if m2m_changed and (
prev_change := ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
if objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
).first()
):
prev_change.postchange_data = objectchange.postchange_data
prev_change.save()
elif objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
queue = events_queue.get()
@@ -106,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
@@ -120,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
objectchange.request_id = request.id
objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
@@ -184,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
# Custom validation
#
def run_validators(instance, validators):
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)
@receiver(post_clean)
def run_save_validators(sender, instance, **kwargs):
"""
Run any custom validation rules for the model prior to calling save().
"""
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators)
@receiver(pre_delete)
def run_delete_validators(sender, instance, **kwargs):
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(
message=e
)
)
#
# Tags
#
@@ -251,7 +257,8 @@ def process_job_start_event_rules(sender, **kwargs):
Process event rules for jobs starting.
"""
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, sender.user.username)
username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
@receiver(job_end)
@@ -260,4 +267,5 @@ def process_job_end_event_rules(sender, **kwargs):
Process event rules for jobs terminating.
"""
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, sender.user.username)
username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)

View File

@@ -14,7 +14,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model()
@@ -251,6 +250,23 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_invalid_choice_items(self):
"""
Attempting to define each choice as a single-item list should return a 400 error.
"""
self.add_permissions('extras.add_customfieldchoiceset')
data = {
"name": "test",
"extra_choices": [
["choice1"],
["choice2"],
["choice3"],
]
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(response.status_code, 400)
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink

View File

@@ -270,7 +270,12 @@ class ConfigContextTest(TestCase):
tag = Tag.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
cluster = Cluster.objects.create(
name="Cluster",
group=cluster_group,
type=cluster_type,
site=site,
)
region_context = ConfigContext.objects.create(
name="region",
@@ -354,6 +359,41 @@ class ConfigContextTest(TestCase):
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
def test_virtualmachine_site_context(self):
"""
Check that config context associated with a site applies to a VM whether the VM is assigned
directly to that site or via its cluster.
"""
site = Site.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site)
vm_role = DeviceRole.objects.first()
# Create a ConfigContext associated with the site
context = ConfigContext.objects.create(
name="context1",
weight=100,
data={"foo": True}
)
context.sites.add(site)
# Create one VM assigned directly to the site, and one assigned via the cluster
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
# Check that their individually-rendered config contexts are identical
self.assertEqual(
vm1.get_config_context(),
vm2.get_config_context()
)
# Check that their annotated config contexts are identical
vms = VirtualMachine.objects.filter(pk__in=(vm1.pk, vm2.pk)).annotate_config_context_data()
self.assertEqual(
vms[0].get_config_context(),
vms[1].get_config_context()
)
def test_multiple_tags_return_distinct_objects(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.

View File

@@ -1,3 +1,5 @@
import importlib
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -149,3 +151,21 @@ class CustomValidator:
if field is not None:
raise ValidationError({field: message})
raise ValidationError(message)
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)

View File

@@ -1057,16 +1057,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_report_module(module, request)
report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
report.result = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.name,
report.result = jobs.filter(
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'module': module,
'report': report,
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
@@ -1078,6 +1076,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
module = get_report_module(module, request)
report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
if form.is_valid():
@@ -1086,6 +1085,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
if not get_workers_for_queue('default'):
messages.error(request, "Unable to run report: RQ worker process not running.")
return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'report': report,
})
@@ -1103,6 +1103,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return redirect('extras:report_result', job_pk=job.pk)
return render(request, 'extras/report.html', {
'job_count': jobs.count(),
'module': module,
'report': report,
'form': form,
@@ -1117,8 +1118,10 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_report_module(module, request)
report = module.reports[name]()
jobs = module.get_jobs(report.class_name)
return render(request, 'extras/report/source.html', {
'job_count': jobs.count(),
'module': module,
'report': report,
'tab': 'source',
@@ -1133,13 +1136,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_report_module(module, request)
report = module.reports[name]()
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.class_name
)
jobs = module.get_jobs(report.class_name)
jobs_table = JobTable(
data=jobs,
@@ -1149,6 +1146,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs_table.configure(request)
return render(request, 'extras/report/jobs.html', {
'job_count': jobs.count(),
'module': module,
'report': report,
'table': jobs_table,
@@ -1232,19 +1230,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending Job (use the latest one by creation timestamp)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=script.name,
).exclude(
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module,
'script': script,
'form': form,
@@ -1256,6 +1246,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
form = script.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
@@ -1279,6 +1270,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', {
'job_count': jobs.count(),
'module': module,
'script': script,
'form': form,
@@ -1293,8 +1285,10 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
jobs = module.get_jobs(script.class_name)
return render(request, 'extras/script/source.html', {
'job_count': jobs.count(),
'module': module,
'script': script,
'tab': 'source',
@@ -1309,13 +1303,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
def get(self, request, module, name):
module = get_script_module(module, request)
script = module.scripts[name]()
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=script.class_name
)
jobs = module.get_jobs(script.class_name)
jobs_table = JobTable(
data=jobs,
@@ -1325,6 +1313,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
jobs_table.configure(request)
return render(request, 'extras/script/jobs.html', {
'job_count': jobs.count(),
'module': module,
'script': script,
'table': jobs_table,

View File

@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
group = NestedFHRPGroupSerializer()
class Meta:
model = models.FHRPGroupAssignment
fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
#

View File

@@ -3,6 +3,7 @@ from copy import deepcopy
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema
from netaddr import IPSet
@@ -379,7 +380,7 @@ class AvailablePrefixesView(AvailableObjectsView):
'vrf': parent.vrf.pk if parent.vrf else None,
})
else:
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
return requested_objects

View File

@@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.utils.translation import gettext as _
from netaddr import AddrFormatError, IPNetwork
from . import lookups, validators
@@ -32,7 +33,7 @@ class BaseIPField(models.Field):
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
return IPNetwork(value)
except AddrFormatError:
raise ValidationError("Invalid IP address format: {}".format(value))
raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
except (TypeError, ValueError) as e:
raise ValidationError(e)

View File

@@ -1,6 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.utils.translation import gettext_lazy as _
from netaddr import IPAddress, IPNetwork, AddrFormatError
@@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
class IPAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
}
def to_python(self, value):
@@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
try:
validate_ipv6_address(value)
except ValidationError:
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
try:
return IPAddress(value)
except ValueError:
raise ValidationError('This field requires an IP address without a mask.')
raise ValidationError(_('This field requires an IP address without a mask.'))
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
class IPNetworkFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
}
def to_python(self, value):
@@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
if len(value.split('/')) != 2:
raise ValidationError('CIDR mask (e.g. /24) is required.')
raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
try:
return IPNetwork(value)
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))

View File

@@ -254,7 +254,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
mark_utilized = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Treat as 100% utilized')
label=_('Treat as fully utilized')
)
description = forms.CharField(
label=_('Description'),
@@ -298,7 +298,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
mark_utilized = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Treat as 100% utilized')
label=_('Treat as fully utilized')
)
description = forms.CharField(
label=_('Description'),

View File

@@ -240,7 +240,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Marked as 100% utilized'),
label=_('Treat as fully utilized'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
@@ -279,7 +279,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Marked as 100% utilized'),
label=_('Treat as fully utilized'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)

View File

@@ -751,4 +751,4 @@ class ServiceCreateForm(ServiceForm):
if not self.cleaned_data['description']:
self.cleaned_data['description'] = service_template.description
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))

View File

@@ -268,7 +268,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'),
default=False,
help_text=_("Treat as 100% utilized")
help_text=_("Treat as fully utilized")
)
# Cached depth & child counts
@@ -427,10 +427,10 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
child_ranges = netaddr.IPSet()
child_ranges = []
for iprange in self.get_child_ranges():
child_ranges.add(iprange.range)
available_ips = prefix - child_ips - child_ranges
child_ranges.append(iprange.range)
available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
@@ -535,7 +535,7 @@ class IPRange(PrimaryModel):
mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'),
default=False,
help_text=_("Treat as 100% utilized")
help_text=_("Treat as fully utilized")
)
clone_fields = (

View File

@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroupAssignment
brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
bulk_update_data = {
'priority': 100,
}

View File

@@ -1,14 +1,19 @@
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator
from django.utils.translation import gettext_lazy as _
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
raise ValidationError(
_("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
prefix=prefix, suggested=prefix.cidr
)
)
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
message = _('The prefix length must be less than or equal to %(limit_value)s.')
code = 'max_prefix_length'
def compare(self, a, b):
@@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
message = _('The prefix length must be greater than or equal to %(limit_value)s.')
code = 'min_prefix_length'
def compare(self, a, b):
@@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
DNSValidator = RegexValidator(
regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
code='invalid'
)

View File

@@ -1068,6 +1068,12 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
return instance
def get_extra_addanother_params(self, request):
return {
'interface_type': request.GET.get('interface_type'),
'interface_id': request.GET.get('interface_id'),
}
@register_model_view(FHRPGroupAssignment, 'delete')
class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView):

View File

@@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from netaddr import IPNetwork
@@ -58,11 +59,11 @@ class ChoiceField(serializers.Field):
if data == '':
if self.allow_blank:
return data
raise ValidationError("This field may not be blank.")
raise ValidationError(_("This field may not be blank."))
# Provide an explicit error message if the request is trying to write a dict or list
if isinstance(data, (dict, list)):
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
raise ValidationError(_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.'))
# Check for string representations of boolean/integer values
if hasattr(data, 'lower'):
@@ -82,7 +83,7 @@ class ChoiceField(serializers.Field):
except TypeError: # Input is an unhashable type
pass
raise ValidationError(f"{data} is not a valid choice.")
raise ValidationError(_("{value} is not a valid choice.").format(value=data))
@property
def choices(self):
@@ -95,8 +96,8 @@ class ContentTypeField(RelatedField):
Represent a ContentType as '<app_label>.<model>'
"""
default_error_messages = {
"does_not_exist": "Invalid content type: {content_type}",
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
"does_not_exist": _("Invalid content type: {content_type}"),
"invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
}
def to_internal_value(self, data):

View File

@@ -1,4 +1,5 @@
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
try:
return queryset.get(**params)
except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided attributes: {params}")
raise ValidationError(
_("Related object not found using the provided attributes: {params}").format(params=params))
except MultipleObjectsReturned:
raise ValidationError(f"Multiple objects match the provided attributes: {params}")
raise ValidationError(
_("Multiple objects match the provided attributes: {params}").format(params=params)
)
except FieldError as e:
raise ValidationError(e)
@@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
pk = int(data)
except (TypeError, ValueError):
raise ValidationError(
f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
f"unrecognized value: {data}"
_(
"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
"unrecognized value: {value}"
).format(value=data)
)
# Look up object by PK
try:
return self.Meta.model.objects.get(pk=pk)
except ObjectDoesNotExist:
raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _Rem
from django.contrib.auth.models import Group, AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
from users.models import ObjectPermission
@@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
'hubspot': ('HubSpot', 'hubspot'),
'keycloak': ('Keycloak', None),
'microsoft-graph': ('Microsoft Graph', 'microsoft'),
'oidc': ('OpenID Connect', None),
'okta': ('Okta', None),
'okta-openidconnect': ('Okta (OIDC)', None),
'salesforce-oauth2': ('Salesforce', 'salesforce'),
@@ -132,7 +134,9 @@ class ObjectPermissionMixin:
# Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.concrete_model
if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}")
raise ValueError(_("Invalid permission {permission} for model {model}").format(
permission=perm, model=model
))
# Compile a QuerySet filter that matches all instances of the specified model
tokens = {

View File

@@ -4,6 +4,7 @@ import threading
from django.conf import settings
from django.core.cache import cache
from django.db.utils import DatabaseError
from django.utils.translation import gettext_lazy as _
from .parameters import PARAMS
@@ -63,7 +64,7 @@ class Config:
if item in self.defaults:
return self.defaults[item]
raise AttributeError(f"Invalid configuration parameter: {item}")
raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item))
def _populate_from_cache(self):
"""Populate config data from Redis cache"""

View File

@@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'

View File

@@ -35,7 +35,9 @@ class CustomFieldsMixin:
Return the ContentType of the form's model.
"""
if not getattr(self, 'model', None):
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
raise NotImplementedError(_("{class_name} must specify a model class.").format(
class_name=self.__class__.__name__
))
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):

View File

@@ -275,16 +275,20 @@ class CustomFieldsMixin(models.Model):
# Validate all field values
for field_name, value in self.custom_field_data.items():
if field_name not in custom_fields:
raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
name=field_name
))
try:
custom_fields[field_name].validate(value)
except ValidationError as e:
raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
name=field_name, error=e.message
))
# Check for missing required values
for cf in custom_fields.values():
if cf.required and cf.name not in self.custom_field_data:
raise ValidationError(f"Missing required custom field '{cf.name}'.")
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
class CustomLinksMixin(models.Model):
@@ -489,10 +493,10 @@ class SyncedDataMixin(models.Model):
# Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled:
AutoSyncRecord.objects.get_or_create(
datafile=self.data_file,
AutoSyncRecord.objects.update_or_create(
object_type=content_type,
object_id=self.pk
object_id=self.pk,
defaults={'datafile': self.data_file}
)
else:
AutoSyncRecord.objects.filter(
@@ -547,7 +551,9 @@ class SyncedDataMixin(models.Model):
Inheriting models must override this method with specific logic to copy data from the assigned DataFile
to the local instance. This method should *NOT* call save() on the instance.
"""
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
class_name=self.__class__
))
#

View File

@@ -1,6 +1,7 @@
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
from django.utils.translation import gettext as _
__all__ = (
'PluginMenu',
@@ -42,11 +43,11 @@ class PluginMenuItem:
self.staff_only = staff_only
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions
if buttons is not None:
if type(buttons) not in (list, tuple):
raise TypeError("Buttons must be passed as a tuple or list.")
raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons
@@ -64,9 +65,9 @@ class PluginMenuButton:
self.icon_class = icon_class
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError("Permissions must be passed as a tuple or list.")
raise TypeError(_("Permissions must be passed as a tuple or list."))
self.permissions = permissions
if color is not None:
if color not in ButtonColorChoices.values():
raise ValueError("Button color must be a choice within ButtonColorChoices.")
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
self.color = color

View File

@@ -1,5 +1,6 @@
import inspect
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
@@ -20,18 +21,32 @@ def register_template_extensions(class_list):
# Validation
for template_extension in class_list:
if not inspect.isclass(template_extension):
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
raise TypeError(
_("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
template_extension=template_extension
)
)
if not issubclass(template_extension, PluginTemplateExtension):
raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
raise TypeError(
_("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
template_extension=template_extension
)
)
if template_extension.model is None:
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
raise TypeError(
_("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
template_extension=template_extension
)
)
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
item=menu_link
))
registry['plugins']['menus'].append(menu)
@@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
# Validation
for menu_link in class_list:
if not isinstance(menu_link, PluginMenuItem):
raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
menu_link=menu_link
))
for button in menu_link.buttons:
if not isinstance(button, PluginMenuButton):
raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
button=button
))
registry['plugins']['menu_items'][section_name] = class_list

View File

@@ -1,4 +1,5 @@
from django.template.loader import get_template
from django.utils.translation import gettext as _
__all__ = (
'PluginTemplateExtension',
@@ -31,7 +32,7 @@ class PluginTemplateExtension:
if extra_context is None:
extra_context = {}
elif not isinstance(extra_context, dict):
raise TypeError("extra_context must be a dictionary")
raise TypeError(_("extra_context must be a dictionary"))
return get_template(template_name).render({**self.context, **extra_context})

View File

@@ -1,4 +1,5 @@
import collections
from django.utils.translation import gettext as _
class Registry(dict):
@@ -10,13 +11,13 @@ class Registry(dict):
try:
return super().__getitem__(key)
except KeyError:
raise KeyError(f"Invalid store: {key}")
raise KeyError(_("Invalid store: {key}").format(key=key))
def __setitem__(self, key, value):
raise TypeError("Cannot add stores to registry after initialization")
raise TypeError(_("Cannot add stores to registry after initialization"))
def __delitem__(self, key):
raise TypeError("Cannot delete stores from registry")
raise TypeError(_("Cannot delete stores from registry"))
# Initialize the global registry

View File

@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup
#
VERSION = '3.7.1'
VERSION = '3.7.3'
# Hostname
HOSTNAME = platform.node()
@@ -122,7 +122,6 @@ EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
GIT_PATH = getattr(configuration, 'GIT_PATH', 'git')
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
@@ -572,7 +571,6 @@ SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user',
'netbox.authentication.user_default_groups_handler',
@@ -726,8 +724,10 @@ LANGUAGES = (
('en', _('English')),
('es', _('Spanish')),
('fr', _('French')),
('ja', _('Japanese')),
('pt', _('Portuguese')),
('ru', _('Russian')),
('tr', _('Turkish')),
)
LOCALE_PATHS = (

View File

@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='DummyModel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=20)),
('number', models.IntegerField(default=100)),
],

View File

@@ -14,6 +14,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_tables2.export import TableExport
from extras.models import ExportTemplate
@@ -320,7 +321,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if type(field.widget) is not HiddenInput
}
def _save_object(self, model_form, request):
def _save_object(self, import_form, model_form, request):
# Save the primary object
obj = self.save_object(model_form, request)
@@ -345,11 +346,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
related_obj = f.save()
related_obj_pks.append(related_obj.pk)
else:
# Replicate errors on the related object form to the primary form for display
# Replicate errors on the related object form to the import form for display and abort
for subfield_name, errors in f.errors.items():
for err in errors:
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
model_form.add_error(None, err_msg)
if subfield_name == '__all__':
err_msg = f"{field_name}[{i}]: {err}"
else:
err_msg = f"{field_name}[{i}] {subfield_name}: {err}"
import_form.add_error(None, err_msg)
raise AbortTransaction()
# Enforce object-level permissions on related objects
@@ -390,7 +394,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
try:
instance = prefetched_objects[object_id]
except KeyError:
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
raise ValidationError('')
# Take a snapshot for change logging
@@ -416,7 +420,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
restrict_form_fields(model_form, request.user)
if model_form.is_valid():
obj = self._save_object(model_form, request)
obj = self._save_object(form, model_form, request)
saved_objects.append(obj)
else:
# Replicate model form errors for display

View File

@@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.signals import clear_events
from utilities.error_handlers import handle_protectederror
@@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
request: The current request
parent: The parent object
"""
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
raise NotImplementedError(_('{class_name} must implement get_children()').format(
class_name=self.__class__.__name__
))
def prep_table_data(self, request, queryset, parent):
"""

View File

@@ -2,14 +2,17 @@ import re
from collections import namedtuple
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_tables2 import RequestConfig
from packaging import version
from extras.dashboard.utils import get_dashboard
from extras.constants import DEFAULT_DASHBOARD
from extras.dashboard.utils import get_dashboard, get_default_dashboard
from netbox.forms import SearchForm
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
@@ -33,7 +36,13 @@ class HomeView(View):
return redirect('login')
# Construct the user's custom dashboard layout
dashboard = get_dashboard(request.user).get_layout()
try:
dashboard = get_dashboard(request.user).get_layout()
except Exception:
messages.error(request, _(
"There was an error loading the dashboard configuration. A default dashboard is in use."
))
dashboard = get_default_dashboard(config=DEFAULT_DASHBOARD).get_layout()
# Check whether a new release is available. (Only for staff/superusers.)
new_release = None

View File

@@ -34,6 +34,10 @@
<th scope="row">{% trans "Account Created" %}</th>
<td>{{ request.user.date_joined|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Login" %}</th>
<td>{{ request.user.last_login|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Superuser" %}</th>
<td>{% checkmark request.user.is_superuser %}</td>

View File

@@ -41,7 +41,7 @@
<td>{{ object.u_height|floatformat }}</td>
</tr>
<tr>
<td>{% trans "Exclude From Utilization" %})</td>
<td>{% trans "Exclude From Utilization" %}</td>
<td>{% checkmark object.exclude_from_utilization %}</td>
</tr>
<tr>

View File

@@ -1,45 +1,37 @@
{% extends 'dcim/devicetype/base.html' %}
{% load render_table from django_tables2 %}
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% load perms %}
{% block content %}
{% if perms.dcim.change_devicetype %}
<form method="post">
{% csrf_token %}
<div class="card">
<h5 class="card-header">{{ title }}</h5>
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
<div class="card-footer noprint">
{% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
<span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
</button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-sm btn-danger">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
<div class="float-end">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add" %} {{ title }}
</a>
</div>
<div class="clearfix"></div>
</div>
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if request.user|can_add:child_model %}
<div class="bulk-button-group">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% trans "Add" %} {{ title }}
</a>
</div>
</form>
{% else %}
<div class="card">
<h5 class="card-header">{{ title }}</h5>
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
{% endif %}
{% endblock content %}
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -34,7 +34,7 @@
</li>
<li class="nav-item" role="presentation">
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}">
{% trans "Jobs" %} {% badge module.jobs.count %}
{% trans "Jobs" %} {% badge job_count %}
</a>
</li>
</ul>

View File

@@ -33,7 +33,7 @@
</li>
<li class="nav-item" role="presentation">
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">
{% trans "Jobs" %} {% badge module.jobs.count %}
{% trans "Jobs" %} {% badge job_count %}
</a>
</li>
</ul>

View File

@@ -134,7 +134,7 @@
{% with first_available_ip=object.get_first_available_ip %}
{% if first_available_ip %}
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}">{{ first_available_ip }}</a>
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}">{{ first_available_ip }}</a>
{% else %}
{{ first_available_ip }}
{% endif %}

View File

@@ -30,6 +30,10 @@
<th scope="row">{% trans "Account Created" %}</th>
<td>{{ object.date_joined|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Login" %}</th>
<td>{{ object.last_login|annotated_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Active" %}</th>
<td>{% checkmark object.is_active %}</td>

View File

@@ -13,7 +13,7 @@
<div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}">
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}">
{% trans "VLAN" %}
</button>
</li>
@@ -32,7 +32,7 @@
</div>
<div class="row mb-3">
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
<div class="tab-pane {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
{% render_field form.vlan %}
</div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">

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