Compare commits

...

128 Commits

Author SHA1 Message Date
Jeremy Stretch
d115601da3 Merge pull request #15805 from netbox-community/develop
Release v3.7.6
2024-04-22 12:18:27 -04:00
Jeremy Stretch
a61e20849b Release v3.7.6 2024-04-22 11:46:03 -04:00
Arthur Hanson
1eca1c3d17 15803 localize help_text (#15804) 2024-04-22 11:42:20 -04:00
transifex-integration[bot]
5d95d49268 Update translations 2024-04-22 11:28:04 -04:00
Jeremy Stretch
6b8bfe9947 Changelog for #14690, #15541, #15588, #15761, #15771, #15790 2024-04-22 11:25:21 -04:00
Jeremy Stretch
e87877b6ea Fixes #15771: Show id field as supported on all bulk import forms 2024-04-22 11:08:36 -04:00
Jeremy Stretch
ebe504c825 Closes #15664: Restore usage of READTHEDOCS env variable 2024-04-22 09:52:03 -04:00
Markku Leiniö
b6e38b2ebe Closes #14690: Pretty-format JSON fields in the config form (#15623)
* Closes #14690: Pretty-format JSON fields in the config form

* Revert changes

* Use our own JSONField for config parameters for pretty editor outputs

* Compare identity instead of equality
2024-04-22 09:25:16 -04:00
Arthur Hanson
90d0104359 15541 Add component selector to InventoryItemTemplate (#15759)
* 15541 make inventoryitemtemplateform match inventoryitemform

* 15541 set tab active
2024-04-22 08:22:53 -04:00
Arthur Hanson
88facbafbb 15761 filter IKE Proposals on IKE Policy detail view (#15766)
* 15761 filter IKEAProposals on IKEAPolicy detail view

* Add test for ike_policy filter

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-19 17:09:55 -04:00
Jeremy Stretch
c9de3128ca Fixes #15790: Fix live preview support for EventRule comments 2024-04-19 17:09:02 -04:00
Arthur
94c31622ac 15588 set readonly nullable fields as allow_null=True 2024-04-19 16:17:28 -04:00
Jeremy Stretch
3d3c1c315b Update documentation for the DEFAULT_LANGUAGE configuration parameter 2024-04-19 16:15:32 -04:00
Jeff Gehlbach
f4c8f5f5b6 Add link to plugin certification program details in Plugin module of docs. Fixes #15769 2024-04-19 08:49:13 -04:00
Jeremy Stretch
19fe5ef25c Changelog for #15427, #15582, #15635 2024-04-17 16:18:57 -04:00
Arthur Hanson
928014c766 5509 Add Test cases for Custom Fields (#12312)
* 5509 add content type data to model tests create and update

* 5509 update use cf form data

* 5509 update tests to use CustomFieldTypeChoices

* 5509 update tests to check custom fields

* Simplify custom fields used for testing

* Move custom field data functions to testing.utils

* Move validate_custom_field_data() into assertInstanceEqual()

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-17 16:05:05 -04:00
Jeremy Stretch
b5bb732031 Closes #10696: Break out instructions for installing & removing plugins (#15757)
* Closes #10696: Break out instructions for installing & rmeoving plugins

* Misc cleanup
2024-04-17 11:58:14 -04:00
Arthur Hanson
b8cedfcc08 15582 check permissions on specific object when sync request (#15704)
* 15582 check permissions on specific object when sync request

* 15582 move permission check

* Enable translation of error message

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-17 10:09:50 -04:00
Javier de la Puente
c5ae89ad03 Use endpoint_url in S3Backend 2024-04-17 09:59:39 -04:00
Markku Leiniö
4284028bb0 Closes #15727: Add tab template context variable in the plugin doc 2024-04-17 08:30:39 -04:00
Jeremy Stretch
3c3943c809 Convert "needs triage" label to a status indicator 2024-04-15 12:12:35 -04:00
Jeremy Stretch
17e8773c8c Changelog for #15640, #15644, #15654, #15668, #15685 2024-04-15 12:10:33 -04:00
Arthur Hanson
f47b158863 15685 Allow decimal for cable length filter form (#15703)
* 15685 allow decimal for cable length filter

* 15685 allow decimal for cable length filter

* 15685 remove minlenth

* 15685 remove minlenth
2024-04-15 11:24:32 -04:00
Wrage, Florian
f7e4fe2a9c Fixes #15640: add identifier field to search index of l2vpn 2024-04-15 10:53:53 -04:00
Julio Oliveira at Encora
5098422f68 Fixes #15644 - Add the ability to configure HSTS in NetBox (#15683)
* Added SECURE_HSTS_SECONDSm SECURE_HSTS_INCLUDE_SUBDOMAINS, and SECURE_HSTS_PRELOAD to settings.py

* Addressed some PR comments.

* Apply suggestions from code review

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-15 10:19:15 -04:00
Julio-Oliveira-Encora
d7922a68d8 Fixed line 391 in netbox/virtualization/views.py. It was reeplaced "view_virtual_disk" with "view_virtualdisk" 2024-04-15 09:28:21 -04:00
Arthur
54c6d95fbb 15654 check for no termination in TunnelTerminationSerializer 2024-04-15 09:22:58 -04:00
Jeremy Stretch
b7668fbfc3 PRVB 2024-04-04 16:23:16 -04:00
Jeremy Stretch
1c76034069 Merge pull request #15631 from netbox-community/develop
Release v3.7.5
2024-04-04 16:20:14 -04:00
Jeremy Stretch
ad0e476788 Release v3.7.5 2024-04-04 16:06:42 -04:00
Jeremy Stretch
e10f5ec3b4 Update source strings for translation 2024-04-04 15:12:51 -04:00
Jeremy Stretch
48a3f3cb70 Changelog for #14707, #15039, #15598, #15608, #15609 2024-04-04 15:05:49 -04:00
muTeREdO
238fa704b9 add example showing how to order results. (#15627)
* add example showing how to order results.

This addresses issue 15622 by building off filtering example to
show how to order results on a named field.

* Apply suggestions from code review

---------

Co-authored-by: Frank Clements <fclements@scoore.net>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-04 14:21:26 -04:00
padthaitofuhot
3b3511c43c Refactor 32264ac3 to re-separate bulk and single device creation. Fixes #15598. 2024-04-04 14:01:55 -04:00
Markku Leiniö
da13fa5569 Closes #15039: Add Clone button in API token 2024-04-04 13:32:43 -04:00
Markku Leiniö
5b50920c61 Closes #14707: Change 'Interface' to 'Tunnel interface' in VPN tunnel forms 2024-04-04 12:57:35 -04:00
Jeremy Stretch
d9a7b4ee0e Fixes #15609: Fix filtering providers list by assigned ASN 2024-04-04 10:45:57 -04:00
Jeremy Stretch
282dc7a705 Fixes #15608: Avoid caching values of null fields in search index 2024-04-04 10:45:19 -04:00
Jeremy Stretch
e1753c0f9b Fix formatting 2024-04-04 10:03:12 -04:00
Jeremy Stretch
0e94f2e05d Simplify auto-assignment qualification 2024-04-04 09:53:49 -04:00
Jeremy Stretch
1c370f45d0 Add weighted assignments & enable for documentation issue 2024-04-04 09:20:20 -04:00
Jeremy Stretch
24e2fc253a Changelog for #15029, #15102, #15435, #15597 2024-04-03 14:12:35 -04:00
Arthur
fca23c6419 15029 check if duplicate FHRP group assignment 2024-04-03 14:09:32 -04:00
Abhimanyu Saharan
e4984d2883 fixed user and group filter form name #15102 2024-04-03 14:02:11 -04:00
Iain Buclaw
6030c521f4 Fix typo in Add Components dropdown 2024-04-03 13:29:32 -04:00
Arthur
83dad6f771 15597 add button_class choices to import form 2024-04-03 13:06:53 -04:00
Jeremy Stretch
bb4930b62f Change log for #14799, #15502 2024-04-03 08:15:44 -04:00
Daniel Sheppard
7d54357146 Fixes: #15502 - Correct exception on IP import form when VM is target 2024-04-03 08:10:28 -04:00
tobiWu
bbd7ddb7aa Fix #15506 Update documentation for plugins index.md (#15518)
* Update documentation for plugins index.md

You should restart netbox-rq workers if you added a plugin. Otherwise you can't load modules from plugin to custom scripts later.

* Update docs/plugins/index.md

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-03 08:05:47 -04:00
Markku Leiniö
d285edc0c7 Fixes #15583: Update API token provisioning example response (#15584)
* Fixes #15583: Update API token provisioning example response

* Fix 'display' field output
2024-04-03 07:58:46 -04:00
Daniel W. Anner
699dd72597 Adding JSON schema changes to implement and within the generated schema template per issue #15555 2024-03-28 16:34:56 -04:00
teapot
3cb68e4bc0 Fixes #15567: Correct typo in help text
Fixes #15567: Correct typo in help text
2024-03-28 16:32:25 -04:00
Jeremy Stretch
d37a6210fa Limit auto-assignment to bug reports & feature requests 2024-03-28 14:14:34 -04:00
Jeremy Stretch
69c0aac105 Add Jeff; remove duplicate entries from rotation (not supported) 2024-03-28 11:48:15 -04:00
Jeremy Stretch
da6a1ef03e Clean up the Markdown reference guide 2024-03-26 16:26:47 -04:00
Jeremy Stretch
d2fee88600 Apply "needs triage" label to new issues by default 2024-03-25 10:39:17 -04:00
Jeremy Stretch
710f9e3c46 Add auto-assign-issue GitHub action 2024-03-25 09:57:13 -04:00
Jeremy Stretch
59e12e73c2 Clean up GitHub actions 2024-03-25 09:55:21 -04:00
Arthur
6f9f1d9d43 14799 dont cache report member names 2024-03-22 10:27:33 -04:00
Jeremy Stretch
35e20d156d Add link to NetBox Enterprise 2024-03-21 09:05:34 -04:00
Jeremy Stretch
4adb44f60d PRVB 2024-03-13 19:37:28 -04:00
Jeremy Stretch
c2cabe0273 Merge pull request #15423 from netbox-community/develop
Release v3.7.4
2024-03-13 19:36:44 -04:00
Jeremy Stretch
06bdfdc9e8 Release v3.7.4 2024-03-13 19:23:51 -04:00
Jeremy Stretch
df7905d257 Changelog for #13722, #14206, #14366, #14832, #15322, #15347, #15356 2024-03-13 19:15:35 -04:00
Jeremy Stretch
8bdbb49a27 Fixes #15322: Add description field to YAML export for device & module types 2024-03-13 19:10:49 -04:00
Jeremy Stretch
7ac21690e5 Fixes #15356: Fix assignment of front & rear images to device types via REST API 2024-03-13 19:10:30 -04:00
Jeremy Stretch
7350950e88 Fixes #15347: Fix querying virtual machine contacts via GraphQL 2024-03-13 19:09:52 -04:00
Jeremy Stretch
8fe3f5e3fd Closes #14366: Enable custom links on ConfigContexts and ConfigTemplates 2024-03-13 14:44:41 -04:00
Jeremy Stretch
44f7ab0970 Add NetBox Enterprise deployment type 2024-03-12 10:57:14 -04:00
Jeremy Stretch
eca2a77584 Closes #14459: Update coverage report 2024-03-12 10:51:29 -04:00
Markku Leiniö
51b2bcf264 Closes #14206: Add FC SFP types 2024-03-12 09:03:42 -04:00
Daniel Sheppard
1ff4e1287f Fixes: #13722 - Correct range expansion code when a numeric set is used (#15301)
* Fixes: #13722 - Correct range expansion code when a numeric set is used

* Correct to my own suggestion

* Clean up logic

* Simplify range detection

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-03-11 10:50:10 -04:00
Leo Chen
f0e137133f Fixes: #14832 Extend GraphQL FHRPGroupType with IPAddressesMixin 2024-03-11 10:32:31 -04:00
Jeremy Stretch
de622801f1 Changelog for #15220, #15232, #15241, #15243, #15316 2024-03-08 17:05:10 -05:00
Jeremy Stretch
eeb732d96e Fixes #15336: Correct label for recurring scheduled jobs 2024-03-08 17:03:18 -05:00
Jeremy Stretch
8bb49d2296 Closes #15291: Add tunnel termination buttons to VM interfaces table 2024-03-08 16:58:04 -05:00
Jeremy Stretch
6629c94148 Closes #15297: Linkify platform column in device & virtual machine tables 2024-03-08 16:48:39 -05:00
Jeremy Stretch
bdcf4c4154 Fixes #15220: Move IP mask validation logic from form to model 2024-03-01 11:28:48 -05:00
Jeff Gehlbach
c45acf0a7c Fixes: Use systemctl enable --now shortcut in docs #15249 2024-02-29 16:01:53 -05:00
Arthur
8afbb4421b 15232 fix inventory item template permission 2024-02-29 15:30:51 -05:00
Jeremy Stretch
55ef24d56d Fixes #15316: Fix selection of 3DES encryption for IKE & IPSec proposals 2024-02-29 14:54:41 -05:00
Abhimanyu Saharan
edb7d24b45 Added installed_module on NestedModuleBaySerializer (#15245)
* added installed_module on NestedModuleBaySerializer #15243

* Update test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-02-23 15:54:47 -05:00
Abhimanyu Saharan
17ec264f3a added display on virtual disk api #15241 2024-02-23 15:33:35 -05:00
Jeremy Stretch
c21ec2139d Delete obsolete file 2024-02-23 10:15:14 -05:00
Jeremy Stretch
d7e7137582 PRVB 2024-02-21 16:04:04 -05:00
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
160 changed files with 13123 additions and 7871 deletions

View File

@@ -1,7 +1,7 @@
---
name: 🐛 Bug Report
description: Report a reproducible bug in the current release of NetBox
labels: ["type: bug"]
labels: ["type: bug", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -13,17 +13,20 @@ 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
- NetBox Enterprise
- Self-hosted
validations:
required: true
- type: input
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v3.7.2
placeholder: v3.7.6
validations:
required: true
- type: dropdown

View File

@@ -1,7 +1,7 @@
---
name: 📖 Documentation Change
description: Suggest an addition or modification to the NetBox documentation
labels: ["type: documentation"]
labels: ["type: documentation", "status: needs triage"]
body:
- type: dropdown
attributes:

View File

@@ -1,7 +1,7 @@
---
name: ✨ Feature Request
description: Propose a new NetBox feature or enhancement
labels: ["type: feature"]
labels: ["type: feature", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.7.2
placeholder: v3.7.6
validations:
required: true
- type: dropdown

21
.github/workflows/auto-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
# auto-assign-issue (https://github.com/marketplace/actions/auto-assign-issue)
name: Issue assignment
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- uses: pozil/auto-assign-issue@v1
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with:
# Weighted assignments
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
numOfAssignee: 1
abortIfPreviousAssignees: true

View File

@@ -84,4 +84,4 @@ jobs:
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*
run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*'

View File

@@ -1,5 +1,5 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs'
name: Close stale issues/PRs
on:
schedule:
@@ -12,10 +12,9 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
- uses: actions/stale@v9
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an

View File

@@ -1,5 +1,5 @@
# lock-threads (https://github.com/marketplace/actions/lock-threads)
name: 'Lock threads'
name: Lock threads
on:
schedule:

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-6-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>
@@ -84,7 +84,7 @@ NetBox automatically logs the creation, modification, and deletion of all manage
<p align="center">
<a href="https://netboxlabs.com/netbox-cloud/"><img src="docs/media/misc/netbox_cloud.png" alt="NetBox Cloud" /></a><br />
Looking for an enterprise solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong>!
Looking for a managed solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> or <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>!
</p>
## Get Involved

View File

@@ -61,7 +61,8 @@ django-timezone-field
# A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/
djangorestframework
# Pinned to 3.14 for NetBox v3.7
djangorestframework<3.15
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -101,11 +102,11 @@ markdown-include
mkdocs-material
# Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
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

@@ -1,5 +1,7 @@
{
"type": "object",
"$id": "urn:devicetype-library:generated-schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"definitions": {
"airflow": {
@@ -384,7 +386,10 @@
"8gfc-sfpp",
"16gfc-sfpp",
"32gfc-sfp28",
"32gfc-sfpp",
"64gfc-qsfpp",
"64gfc-sfpdd",
"64gfc-sfpp",
"128gfc-qsfp28",
"infiniband-sdr",
"infiniband-ddr",

View File

@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
{# Disable search indexing unless we're building for ReadTheDocs #}
{% if not config.extra.readthedocs %}
<meta name="robots" content="noindex">
{% endif %}
{% endblock %}

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

@@ -183,6 +183,30 @@ The view name or URL to which a user is redirected after logging out.
---
## SECURE_HSTS_INCLUDE_SUBDOMAINS
Default: False
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
---
## SECURE_HSTS_PRELOAD
Default: False
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
---
## SECURE_HSTS_SECONDS
Default: 0
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
---
## SECURE_SSL_REDIRECT
Default: False

View File

@@ -16,10 +16,7 @@ BASE_PATH = 'netbox/'
Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
---

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

@@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da
Once PostgreSQL has been installed, start the service and enable it to run at boot:
```no-highlight
sudo systemctl start postgresql
sudo systemctl enable postgresql
sudo systemctl enable --now postgresql
```
Before continuing, verify that you have installed PostgreSQL 12 or later:

View File

@@ -14,8 +14,7 @@
```no-highlight
sudo yum install -y redis
sudo systemctl start redis
sudo systemctl enable redis
sudo systemctl enable --now redis
```
Before continuing, verify that your installed version of Redis is at least v4.0:

View File

@@ -27,8 +27,7 @@ sudo systemctl daemon-reload
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
sudo systemctl start netbox netbox-rq
sudo systemctl enable netbox netbox-rq
sudo systemctl enable --now netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:

View File

@@ -85,13 +85,19 @@ Each model generally has two views associated with it: a list view and a detail
* `/api/dcim/devices/` - List existing devices or create a new device
* `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
Lists of objects can be filtered and ordered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
```
GET /api/dcim/interfaces/?device_id=123
```
See the [filtering documentation](../reference/filtering.md) for more details.
An optional `ordering` parameter can be used to define how to sort the results. Building off the previous example, to sort all the interfaces in reverse order of creation (newest to oldest) for a device with ID 123:
```
GET /api/dcim/interfaces/?device_id=123&ordering=-created
```
See the [filtering documentation](../reference/filtering.md) for more details on topics related to filtering, ordering and lookup expressions.
## Serialization
@@ -647,18 +653,20 @@ Note that we are _not_ passing an existing REST API token with this request. If
{
"id": 6,
"url": "https://netbox/api/users/tokens/6/",
"display": "3c9cb9 (hankhill)",
"display": "**********************************3c9cb9",
"user": {
"id": 2,
"url": "https://netbox/api/users/users/2/",
"display": "hankhill",
"username": "hankhill"
},
"created": "2021-06-11T20:09:13.339367Z",
"created": "2024-03-11T20:09:13.339367Z",
"expires": null,
"last_used": null,
"key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9",
"write_enabled": true,
"description": ""
"description": "",
"allowed_ips": []
}
```

View File

@@ -62,7 +62,7 @@ class MyModelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Assigned site'
help_text=_('Assigned site')
)
class Meta:

View File

@@ -3,6 +3,9 @@
!!! tip "Plugins Development Tutorial"
Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time!
!!! tip "Plugin Certification Program"
NetBox Labs offers a [**Plugin Certification Program**](https://github.com/netbox-community/netbox/wiki/Plugin-Certification-Program) for plugin developers interested in establishing a co-maintainer relationship. The program aims to assure ongoing compatibility, maintainability, and commercial supportability of key plugins.
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
!!! info "Django Development"

View File

@@ -157,7 +157,7 @@ These views are provided to enable or enhance certain NetBox model features, suc
### Additional Tabs
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`:
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`, and add it to the template context dict:
```python
from dcim.models import Site
@@ -173,6 +173,16 @@ class MyView(generic.ObjectView):
badge=lambda obj: Stuff.objects.filter(site=obj).count(),
permission='myplugin.view_stuff'
)
def get(self, request, pk):
...
return render(
request,
"myplugin/mytabview.html",
context={
"tab": self.tab,
},
)
```
::: utilities.views.register_model_view

View File

@@ -2,6 +2,8 @@
Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own.
Please see the documented instructions for [installing a plugin](./installation.md) to get started.
## Capabilities
The NetBox plugin architecture allows for the following:
@@ -23,122 +25,3 @@ Either by policy or by technical limitation, the interaction of plugins with Net
* **Override core templates.** Plugins can inject additional content where supported, but may not manipulate or remove core content.
* **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration.
* **Disable core components.** Plugins are not permitted to disable or hide core NetBox components.
## Installing Plugins
The instructions below detail the process for installing and enabling a NetBox plugin.
### Install Package
Download and install the plugin package per its installation instructions. Plugins published via PyPI are typically installed using pip. Be sure to install the plugin within NetBox's virtual environment.
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip install <package>
```
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
### Enable the Plugin
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
```python
PLUGINS = [
'plugin_name',
]
```
### Configure Plugin
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file.
```no-highlight
PLUGINS_CONFIG = {
'plugin_name': {
'foo': 'bar',
'buzz': 'bazz'
}
}
```
### Run Database Migrations
If the plugin introduces new database models, run the provided schema migrations:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py migrate
```
### Collect Static Files
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py collectstatic
```
### Restart WSGI Service
Restart the WSGI service to load the new plugin:
```no-highlight
# sudo systemctl restart netbox
```
## Removing Plugins
Follow these steps to completely remove a plugin.
### Update Configuration
Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`.
### Remove the Python Package
Use `pip` to remove the installed plugin:
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip uninstall <package>
```
### Restart WSGI Service
Restart the WSGI service:
```no-highlight
# sudo systemctl restart netbox
```
### Drop Database Tables
!!! note
This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
```no-highlight
netbox=> \dt pluginname_*
List of relations
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+--------
public | pluginname_foo | table | netbox
public | pluginname_bar | table | netbox
(2 rows)
```
!!! warning
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
Drop each of the listed tables to remove it from the database:
```no-highlight
netbox=> DROP TABLE pluginname_foo;
DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```

View File

@@ -0,0 +1,68 @@
# Installing a Plugin
!!! warning
The instructions below detail the general process for installing and configuring a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to install it.
## Install the Python Package
Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment.
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip install <package>
```
Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
## Enable the Plugin
In `configuration.py`, add the plugin's name to the `PLUGINS` list:
```python
PLUGINS = [
# ...
'plugin_name',
]
```
## Configure the Plugin
If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's `README` file or other documentation.
```no-highlight
PLUGINS_CONFIG = {
'plugin_name': {
'foo': 'bar',
'buzz': 'bazz'
}
}
```
## Run Database Migrations
If the plugin introduces new database models, run the provided schema migrations:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py migrate
```
!!! tip
It's okay to run the `migrate` management command even if the plugin does not include any migration files.
## Collect Static Files
Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py collectstatic
```
### Restart WSGI Service
Finally, restart the WSGI service and RQ workers to load the new plugin:
```no-highlight
# sudo systemctl restart netbox netbox-rq
```

72
docs/plugins/removal.md Normal file
View File

@@ -0,0 +1,72 @@
# Removing a Plugin
!!! warning
The instructions below detail the general process for removing a NetBox plugin. However, each plugin is different and may require additional tasks or modifications to the steps below. Always consult the documentation for a specific plugin **before** attempting to remove it.
## Disable the Plugin
Disable the plugin by removing it from the `PLUGINS` list in `configuration.py`.
## Remove its Configuration
Delete the plugin's entry (if any) in the `PLUGINS_CONFIG` dictionary in `configuration.py`.
!!! tip
If there's a chance you may reinstall the plugin, consider commenting out any configuration parameters instead of deleting them.
## Re-index Search Entries
Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin.
```no-highlight
$ cd /opt/netbox/netbox/
$ source /opt/netbox/venv/bin/activate
(venv) $ python3 manage.py reindex
```
## Uninstall its Python Package
Use `pip` to remove the installed plugin:
```no-highlight
$ source /opt/netbox/venv/bin/activate
(venv) $ pip uninstall <package>
```
## Restart WSGI Service
Restart the WSGI service:
```no-highlight
# sudo systemctl restart netbox
```
## Drop Database Tables
!!! note
This step is necessary only for plugins which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
Enter the PostgreSQL database shell (`manage.py dbshell`) to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
```no-highlight
netbox=> \dt pluginname_*
List of relations
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+--------
public | pluginname_foo | table | netbox
public | pluginname_bar | table | netbox
(2 rows)
```
!!! warning
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
Drop each of the listed tables to remove it from the database:
```no-highlight
netbox=> DROP TABLE pluginname_foo;
DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```

View File

@@ -1,353 +1,254 @@
---
hide:
- toc
---
# Markdown
NetBox supports markdown rendering for certain text fields.
NetBox supports Markdown rendering for certain text fields. Some common examples are provided below. For a complete Markdown reference, please see [Markdownguide.org](https://www.markdownguide.org/basic-syntax/).
## Syntax
##### Table of Contents
[Headers](#headers)
[Emphasis](#emphasis)
[Lists](#lists)
[Links](#links)
[Images](#images)
[Code Blocks](#code)
[Tables](#tables)
[Blockquotes](#blockquotes)
[Inline HTML](#html)
[Horizontal Rule](#hr)
[Line Breaks](#lines)
<a name="headers"></a>
## Headers
## Headings
```no-highlight
# H1
## H2
### H3
#### H4
##### H5
###### H6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<h6>Heading 6</h6>
Alternatively, for H1 and H2, an underline-ish style:
Alt-H1
======
```no-highlight
Heading 1
=========
Alt-H2
------
Heading 2
---------
```
# H1
## H2
### H3
#### H4
##### H5
###### H6
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<a name="emphasis"></a>
## Emphasis
## Text
```no-highlight
Emphasis, aka italics, with *asterisks* or _underscores_.
Strong emphasis, aka bold, with **asterisks** or __underscores__.
Combined emphasis with **asterisks and _underscores_**.
Strikethrough uses two tildes. ~~Scratch this.~~
Italicize text with *asterisks* or _underscores_.
```
Emphasis, aka italics, with *asterisks* or _underscores_.
Strong emphasis, aka bold, with **asterisks** or __underscores__.
Combined emphasis with **asterisks and _underscores_**.
Strikethrough uses two tildes. ~~Scratch this.~~
<a name="lists"></a>
## Lists
(In this example, leading and trailing spaces are shown with with dots: ⋅)
Italicize text with *asterisks* or _underscores_.
```no-highlight
1. First ordered list item
2. Another item
⋅⋅* Unordered sub-list.
1. Actual numbers don't matter, just that it's a number
⋅⋅1. Ordered sub-list
4. And another item.
⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
* Unordered list can use asterisks
- Or minuses
+ Or pluses
Bold text with two **asterisks** or __underscores__.
```
1. First ordered list item
2. Another item
* Unordered sub-list.
1. Actual numbers don't matter, just that it's a number
1. Ordered sub-list
4. And another item.
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
To have a line break without a paragraph, you will need to use two trailing spaces.
Note that this line is separate, but within the same paragraph.
(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
* Unordered list can use asterisks
- Or minuses
+ Or pluses
<a name="links"></a>
## Links
There are two ways to create links.
Bold text with two **asterisks** or __underscores__.
```no-highlight
[I'm an inline-style link](https://www.google.com)
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
[I'm a reference-style link][Arbitrary case-insensitive reference text]
[You can use numbers for reference-style link definitions][1]
Or leave it empty and use the [link text itself].
URLs and URLs in angle brackets will automatically get turned into links.
http://www.example.com or <http://www.example.com> and sometimes
example.com (but not on Github, for example).
Some text to show that the reference links can follow later.
[arbitrary case-insensitive reference text]: https://www.mozilla.org
[1]: http://slashdot.org
[link text itself]: http://www.reddit.com
Strike text with two tildes. ~~Deleted text.~~
```
[I'm an inline-style link](https://www.google.com)
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
[I'm a reference-style link][Arbitrary case-insensitive reference text]
[You can use numbers for reference-style link definitions][1]
Or leave it empty and use the [link text itself].
URLs and URLs in angle brackets will automatically get turned into links.
http://www.example.com or <http://www.example.com> and sometimes
example.com (but not on Github, for example).
Some text to show that the reference links can follow later.
[arbitrary case-insensitive reference text]: https://www.mozilla.org
[1]: http://slashdot.org
[link text itself]: http://www.reddit.com
<a name="images"></a>
## Images
```
Here's the NetBox logo (hover to see the title text):
Inline-style:
![alt text](/media/misc/netbox_logo.png "Logo Title Text 1")
Reference-style:
![alt text][logo]
[logo]: /media/misc/netbox_logo.png "Logo Title Text 2"
```
Here's the NetBox logo (hover to see the title text):
Inline-style:
![alt text](../media/misc/netbox_logo.png "Logo Title Text 1")
Reference-style:
![alt text][logo]
[logo]: ../media/misc/netbox_logo.png "Logo Title Text 2"
<a name="code"></a>
## Code blocks
```
Inline `code` has `back-ticks around` it.
```
Inline `code` has `back-ticks around` it.
Blocks of code are fenced by lines with three back-ticks <code>```</code>
````
```
var s = "Code block";
alert(s);
```
````
```
var s = "Code block";
alert(s);
```
<a name="tables"></a>
## Tables
```no-highlight
Colons can be used to align columns.
| Tables | Are | Cool |
| ------------- |:-------------:| -----:|
| col 3 is | right-aligned | $1600 |
| col 2 is | centered | $12 |
| zebra stripes | are neat | $1 |
There must be at least 3 dashes separating each header cell.
The outer pipes (|) are optional, and you don't need to make the
raw Markdown line up prettily. You can also use inline Markdown.
Markdown | Less | Pretty
--- | --- | ---
*Still* | `renders` | **nicely**
1 | 2 | 3
```
Colons can be used to align columns.
| Tables | Are | Cool |
| ------------- |:-------------:| -----:|
| col 3 is | right-aligned | $1600 |
| col 2 is | centered | $12 |
| zebra stripes | are neat | $1 |
There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
Markdown | Less | Pretty
--- | --- | ---
*Still* | `renders` | **nicely**
1 | 2 | 3
<a name="blockquotes"></a>
## Blockquotes
```no-highlight
> Blockquotes are very handy in email to emulate reply text.
> This line is part of the same quote.
Quote break.
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
```
> Blockquotes are very handy in email to emulate reply text.
> This line is part of the same quote.
Quote break.
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
<a name="html"></a>
## Inline HTML
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
```no-highlight
<dl>
<dt>Definition list</dt>
<dd>Is something people use sometimes.</dd>
<dt>Markdown in HTML</dt>
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
</dl>
```
<dl>
<dt>Definition list</dt>
<dd>Is something people use sometimes.</dd>
<dt>Markdown in HTML</dt>
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
</dl>
<a name="hr"></a>
## Horizontal Rule
```
Three or more...
---
Hyphens
***
Asterisks
___
Underscores
```
Three or more...
---
Hyphens
***
Asterisks
___
Underscores
<a name="lines"></a>
Strike text with two tildes. ~~Deleted text.~~
## Line Breaks
By default, Markdown will remove line breaks between successive lines of text. For example:
```
Here's a line for us to start with.
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
This line is also a separate paragraph, but...
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
```no-highlight
This is one line.
And this is another line.
One more line here.
```
Here's a line for us to start with.
This is one line.
And this is another line.
One more line here.
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
To preserve line breaks, append two spaces to each line (represented below with the `` character).
This line is also begins a separate paragraph, but...
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
```no-highlight
This is one line.⋅⋅
And this is another line.⋅⋅
One more line here.
```
Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)
This is one line.
And this is another line.
One more line here.
## Lists
Use asterisks or hyphens for unordered lists. Indent items by four spaces to start a child list.
```no-highlight
* Alpha
* Bravo
* Charlie
* Child item 1
* Child item 2
* Delta
```
* Alpha
* Bravo
* Charlie
* Child item 1
* Child item 2
* Delta
Use digits followed by periods for ordered (numbered) lists.
```no-highlight
1. Red
2. Green
3. Blue
1. Light blue
2. Dark blue
4. Orange
```
1. Red
2. Green
3. Blue
1. Light blue
2. Dark blue
4. Orange
## Links
Text can be rendered as a hyperlink by encasing it in square brackets, followed by a URL in parentheses. A title (text displayed on hover) may optionally be included as well.
```no-highlight
Here's an [example](https://www.example.com) of a link.
And here's [another link](https://www.example.com "Click me!"), this time with a title.
```
Here's an [example](https://www.example.com) of a link.
And here's [another link](https://www.example.com "Click me!"), with a title.
## Images
The syntax for embedding an image is very similar to that used for a hyperlink. Alternate text should always be provided; this will be displayed if the image fails to load. As with hyperlinks, title text is optional.
```no-highlight
![Alternate text](/path/to/image.png "Image title text")
```
## Code Blocks
Single backticks can be used to annotate code inline. Text enclosed by lines of three backticks will be displayed as a code block.
```no-highlight
Paragraphs are rendered in HTML using `<p>` and `</p>` tags.
```
Paragraphs are rendered in HTML using `<p>` and `</p>` tags.
````
```
def my_func(foo, bar):
# Do something
return foo * bar
```
````
```no-highlight
def my_func(foo, bar):
# Do something
return foo * bar
```
## Tables
Simple tables can be constructed using the pipe character (`|`) to denote columns, and hyphens (`-`) to denote the heading. Inline Markdown can be used to style text within columns.
```no-highlight
| Heading 1 | Heading 2 | Heading 3 |
|-----------|-----------|-----------|
| Row 1 | Alpha | Red |
| Row 2 | **Bravo** | Green |
| Row 3 | Charlie | ~~Blue~~ |
```
| Heading 1 | Heading 2 | Heading 3 |
|-----------|-----------|-----------|
| _Row 1_ | Alpha | Red |
| Row 2 | **Bravo** | Green |
| Row 3 | Charlie | ~~Blue~~ |
Colons can be used to align text to the left or right side of a column.
```no-highlight
| Left-aligned | Centered | Right-aligned |
|:-------------|:--------:|--------------:|
| Text | Text | Text |
| Text | Text | Text |
| Text | Text | Text |
```
| Left-aligned | Centered | Right-aligned |
|:-------------|:--------:|--------------:|
| Text | Text | Text |
| Text | Text | Text |
| Text | Text | Text |
## Blockquotes
Text can be wrapped in a blockquote by prepending a right angle bracket (`>`) before each line.
```no-highlight
> I think that I shall never see
> a graph more lovely than a tree.
> A tree whose crucial property
> is loop-free connectivity.
```
> I think that I shall never see
> a graph more lovely than a tree.
> A tree whose crucial property
> is loop-free connectivity.
Markdown removes line breaks by default. To preserve line breaks, append two spaces to each line (represented below with the `` character).
```no-highlight
> I think that I shall never see⋅⋅
> a graph more lovely than a tree.⋅⋅
> A tree whose crucial property⋅⋅
> is loop-free connectivity.
```
> I think that I shall never see
> a graph more lovely than a tree.
> A tree whose crucial property
> is loop-free connectivity.
## Horizontal Rule
A horizontal rule is a single line rendered across the width of the page using a series of three or more hyphens or asterisks. It can be useful for separating sections of content.
```no-highlight
Content
---
More content
***
Final content
```
Content
---
More content
***
Final content

View File

@@ -1,5 +1,110 @@
# NetBox v3.7
## v3.7.6 (2024-04-22)
### Enhancements
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form
* [#15427](https://github.com/netbox-community/netbox/issues/15427) - Enable compatibility with non-Amazon S3 providers for remote data sources
* [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers
* [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS)
### Bug Fixes
* [#15541](https://github.com/netbox-community/netbox/issues/15541) - Restore ability to modify assigned component template when adding/modifying an inventory item template
* [#15582](https://github.com/netbox-community/netbox/issues/15582) - Fix permission constraints for synchronization of remote data sources
* [#15588](https://github.com/netbox-community/netbox/issues/15588) - Correct OpenAPI schema definitions for read-only fields which may return null values
* [#15635](https://github.com/netbox-community/netbox/issues/15635) - Extend plugin removal instruction to include reindexing the global search cache
* [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination
* [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view
* [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form
* [#15761](https://github.com/netbox-community/netbox/issues/15761) - Add missing `ike_policy` & `ike_policy_id` filters for IKE proposals
* [#15771](https://github.com/netbox-community/netbox/issues/15771) - Include `id` in list of supported fields for all bulk import forms
* [#15790](https://github.com/netbox-community/netbox/issues/15790) - Fix live preview support for EventRule comments
---
## v3.7.5 (2024-04-04)
### Enhancements
* [#14707](https://github.com/netbox-community/netbox/issues/14707) - Clarify interface designation when creating tunnel terminations
* [#15039](https://github.com/netbox-community/netbox/issues/15039) - Allow API tokens to be cloned
### Bug Fixes
* [#14799](https://github.com/netbox-community/netbox/issues/14799) - Avoid caching modified reports & scripts
* [#15029](https://github.com/netbox-community/netbox/issues/15029) - Raise a clean validation error when attempting to make duplicate FHRP group assignments
* [#15102](https://github.com/netbox-community/netbox/issues/15102) - Fix usage of selector widget for form fields referencing users/groups
* [#15435](https://github.com/netbox-community/netbox/issues/15435) - Correct permissions name to allow adding a module bay to a device via the UI
* [#15502](https://github.com/netbox-community/netbox/issues/15502) - Fix KeyError exception when modifying an IP address assigned to a virtual machine
* [#15597](https://github.com/netbox-community/netbox/issues/15597) - Restore help modal for `button_class` field on custom link bulk import form
* [#15598](https://github.com/netbox-community/netbox/issues/15598) - Fix exception when creating a device from a device type with one or more child inventory items
* [#15608](https://github.com/netbox-community/netbox/issues/15608) - Avoid caching values of null fields in search index
* [#15609](https://github.com/netbox-community/netbox/issues/15609) - Fix filtering of the providers list by assigned ASN
---
## v3.7.4 (2024-03-13)
### Enhancements
* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types
* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates
* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table
* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables
### Bug Fixes
* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values
* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL
* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses
* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type
* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API
* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode
* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals
* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types
* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs
* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL
* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API
---
## 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

View File

@@ -42,6 +42,7 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
readthedocs: !ENV READTHEDOCS
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
@@ -127,7 +128,9 @@ nav:
- Synchronized Data: 'integrations/synchronized-data.md'
- Prometheus Metrics: 'integrations/prometheus-metrics.md'
- Plugins:
- Using Plugins: 'plugins/index.md'
- About Plugins: 'plugins/index.md'
- Installing a Plugin: 'plugins/installation.md'
- Removing a Plugin: 'plugins/removal.md'
- Developing Plugins:
- Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md'

View File

@@ -64,6 +64,12 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
queryset=ASN.objects.all(),
label=_('ASN (ID)'),
)
asn = django_filters.ModelMultipleChoiceFilter(
field_name='asns__asn',
queryset=ASN.objects.all(),
to_field_name='asn',
label=_('ASN'),
)
class Meta:
model = Provider

View File

@@ -24,7 +24,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
(_('ASN'), ('asn',)),
(_('ASN'), ('asn_id',)),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -46,10 +46,6 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
asn = forms.IntegerField(
required=False,
label=_('ASN (legacy)')
)
asn_id = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
required=False,

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

@@ -90,10 +90,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn_id(self): # ASN object assignment
def test_asn(self):
asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'asn': [asns[0].asn, asns[1].asn]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]

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

@@ -1,5 +1,5 @@
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -33,10 +33,11 @@ class DataSourceViewSet(NetBoxModelViewSet):
"""
Enqueue a job to synchronize the DataSource.
"""
if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk)
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
datasource.enqueue_sync_job(request)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})

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
@@ -149,7 +149,8 @@ class S3Backend(DataBackend):
region_name=self._region_name,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
config=self.config
config=self.config,
endpoint_url=self._endpoint_url
)
bucket = s3.Bucket(self._bucket_name)
@@ -176,6 +177,11 @@ class S3Backend(DataBackend):
url_path = urlparse(self.url).path.lstrip('/')
return url_path.split('/')[0]
@property
def _endpoint_url(self):
url_path = urlparse(self.url)
return url_path._replace(params="", fragment="", query="", path="").geturl()
@property
def _remote_path(self):
url_path = urlparse(self.url).path.lstrip('/')

View File

@@ -3,6 +3,7 @@ import json
from django import forms
from django.conf import settings
from django.forms.fields import JSONField as _JSONField
from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
@@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from netbox.utils import get_data_backend_choices
from utilities.forms import BootstrapMixin, get_field_value
from utilities.forms.fields import CommentField
from utilities.forms.fields import CommentField, JSONField
from utilities.forms.widgets import HTMXSelect
__all__ = (
@@ -103,9 +104,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
@@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
if param.field is _JSONField:
# Replace with our own JSONField to get pretty JSON in config editor
param.field = JSONField
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)

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

@@ -177,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)
@@ -190,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

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

@@ -414,11 +414,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
class NestedModuleBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
class Meta:
model = models.ModuleBay
fields = ['id', 'url', 'display', 'module', 'name']
fields = ['id', 'url', 'display', 'installed_module', 'name']
class NestedDeviceBaySerializer(WritableNestedSerializer):

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.ImageField(required=False, allow_null=True)
rear_image = serializers.ImageField(required=False, allow_null=True)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
@@ -610,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
required=False,
allow_null=True
)
component = serializers.SerializerMethodField(read_only=True)
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
@@ -666,7 +668,7 @@ class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
device_role = NestedDeviceRoleSerializer(read_only=True, help_text=_('Deprecated in v3.6 in favor of `role`.'))
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
@@ -683,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer):
)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -733,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer):
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True)
config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
class Meta(DeviceSerializer.Meta):
fields = [
@@ -1037,8 +1039,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay
fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields',
'created', 'last_updated',
'custom_fields', 'created', 'last_updated',
]
@@ -1066,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
required=False,
allow_null=True
)
component = serializers.SerializerMethodField(read_only=True)
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:

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

@@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
TYPE_32GFC_SFP28 = '32gfc-sfp28'
TYPE_32GFC_SFP_PLUS = '32gfc-sfpp'
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
TYPE_64GFC_SFP_DD = '64gfc-sfpdd'
TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
# InfiniBand
@@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
(TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'),
(TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
(TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'),
(TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'),
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
)
),

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()
@@ -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
@@ -1350,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text='Assigned role'
help_text=_('Assigned role')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
help_text=_('Assigned tenant')
)
status = CSVChoiceField(
label=_('Status'),

View File

@@ -977,9 +977,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Color'),
required=False
)
length = forms.IntegerField(
length = forms.DecimalField(
label=_('Length'),
required=False
required=False,
)
length_unit = forms.ChoiceField(
label=_('Length unit'),

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'),
@@ -969,21 +976,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
queryset=Manufacturer.objects.all(),
required=False
)
component_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
# Assigned component selectors
consoleporttemplate = DynamicModelChoiceField(
queryset=ConsolePortTemplate.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_type_id': '$device_type'
},
label=_('Console port template')
)
component_id = forms.IntegerField(
consoleserverporttemplate = DynamicModelChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_type_id': '$device_type'
},
label=_('Console server port template')
)
frontporttemplate = DynamicModelChoiceField(
queryset=FrontPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Front port template')
)
interfacetemplate = DynamicModelChoiceField(
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Interface template')
)
poweroutlettemplate = DynamicModelChoiceField(
queryset=PowerOutletTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power outlet template')
)
powerporttemplate = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power port template')
)
rearporttemplate = DynamicModelChoiceField(
queryset=RearPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Rear port template')
)
fieldsets = (
(None, (
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
)),
)
@@ -991,9 +1044,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
model = InventoryItemTemplate
fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selection
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate',
'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component."))
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
#
# Device components

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

@@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'manufacturer': self.manufacturer.name,
'model': self.model,
'slug': self.slug,
'description': self.description,
'default_platform': self.default_platform.name if self.default_platform else None,
'part_number': self.part_number,
'u_height': float(self.u_height),
'is_full_depth': self.is_full_depth,
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'comments': self.comments,
}
# Component templates
@@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'manufacturer': self.manufacturer.name,
'model': self.model,
'part_number': self.part_number,
'comments': self.comments,
'description': self.description,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'comments': self.comments,
}
# Component templates
@@ -875,7 +877,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)
})
@@ -994,17 +996,16 @@ class Device(
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
model = queryset.model.component_model
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
@@ -1017,7 +1018,11 @@ class Device(
update_fields=None
)
else:
for component in components:
for obj in queryset:
component = obj.instantiate(device=self)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
component.custom_field_data = cf_defaults
component.save()
def save(self, *args, **kwargs):

View File

@@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Type')
)
platform = tables.Column(
linkify=True,
verbose_name=_('Platform')
)
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip4', 'primary_ip6'),
@@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = models.Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
@@ -359,6 +363,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

@@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
<div class="table-badge-group">
{% if value.count >= 3 %}
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a>
<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' %}

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'
@@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay
brief_fields = ['display', 'id', 'module', 'name', 'url']
brief_fields = ['display', 'id', 'installed_module', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}

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

@@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
tab = ViewTab(
label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_template_count,
permission='dcim.view_invenotryitemtemplate',
permission='dcim.view_inventoryitemtemplate',
weight=590,
hide_if_empty=True
)
@@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateCreateForm
model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
def alter_object(self, instance, request):
# Set component (if any)
@@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
class InventoryItemTemplateEditView(generic.ObjectEditView):
queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
@register_model_view(InventoryItemTemplate, 'delete')

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,5 +1,6 @@
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
@@ -150,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
@@ -545,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
@@ -595,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):

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
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
)
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

@@ -116,6 +116,12 @@ class CustomLinkImportForm(CSVModelForm):
queryset=ContentType.objects.with_feature('custom_links'),
help_text=_("One or more assigned object types")
)
button_class = CSVChoiceField(
label=_('button class'),
required=False,
choices=CustomLinkButtonClassChoices,
help_text=_('The class of the first link in a group will be used for the dropdown button')
)
class Meta:
model = CustomLink
@@ -202,7 +208,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 +217,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

@@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm):
required=False,
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
)
comments = CommentField()
fieldsets = (
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),

View File

@@ -7,6 +7,7 @@ from extras.models import ObjectChange
__all__ = (
'ChangelogMixin',
'ConfigContextMixin',
'ContactsMixin',
'CustomFieldsMixin',
'ImageAttachmentsMixin',
'JournalEntriesMixin',

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

@@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config
from netbox.registry import registry
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge
@@ -26,7 +26,7 @@ __all__ = (
# Config contexts
#
class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@@ -210,7 +210,7 @@ class ConfigContextModel(models.Model):
# Config templates
#
class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100

View File

@@ -52,7 +52,7 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
def __str__(self):
return self.python_name
@cached_property
@property
def reports(self):
def _get_name(cls):

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

@@ -37,7 +37,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
to='contenttypes.ContentType',
related_name='+',
blank=True,
help_text=_("The object type(s) to which this this tag can be applied.")
help_text=_("The object type(s) to which this tag can be applied.")
)
clone_fields = (

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,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
# Create/update an ObejctChange record for this change
# 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
@@ -108,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:
@@ -122,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)
@@ -186,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
#

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

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

@@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer):
Representation of a VLAN which does not exist in the database.
"""
vid = serializers.IntegerField(read_only=True)
group = NestedVLANGroupSerializer(read_only=True)
group = NestedVLANGroupSerializer(read_only=True, allow_null=True)
def to_representation(self, instance):
return {
@@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
"""
Representation of a prefix which does not exist in the database.
"""
family = serializers.IntegerField(read_only=True)
family = serializers.IntegerField(read_only=True, allow_null=True)
prefix = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True)
vrf = NestedVRFSerializer(read_only=True, allow_null=True)
def to_representation(self, instance):
if self.context.get('vrf'):
@@ -429,9 +429,9 @@ class AvailableIPSerializer(serializers.Serializer):
"""
Representation of an IP address which does not exist in the database.
"""
family = serializers.IntegerField(read_only=True)
family = serializers.IntegerField(read_only=True, allow_null=True)
address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True)
vrf = NestedVRFSerializer(read_only=True, allow_null=True)
description = serializers.CharField(required=False)
def to_representation(self, instance):

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

@@ -378,7 +378,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
# Set as primary for device/VM
if self.cleaned_data.get('is_primary'):
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:

View File

@@ -367,20 +367,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
)
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip)
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
ip=address.ip
)
raise ValidationError(msg)
def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs)
@@ -521,6 +507,24 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
for ipaddress in ipaddresses:
self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
def clean_group(self):
group = self.cleaned_data['group']
conflicting_assignments = FHRPGroupAssignment.objects.filter(
interface_type=self.instance.interface_type,
interface_id=self.instance.interface_id,
group=group
)
if self.instance.id:
conflicting_assignments = conflicting_assignments.exclude(id=self.instance.id)
if conflicting_assignments.exists():
raise forms.ValidationError(
_('Assignment already exists')
)
return group
class VLANGroupForm(NetBoxModelForm):
scope_type = ContentTypeChoiceField(
@@ -751,4 +755,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

@@ -1,6 +1,7 @@
import graphene
from ipam import filtersets, models
from .mixins import IPAddressesMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
filterset_class = filtersets.AggregateFilterSet
class FHRPGroupType(NetBoxObjectType):
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
class Meta:
model = models.FHRPGroup

View File

@@ -844,6 +844,25 @@ class IPAddress(PrimaryModel):
'address': _("Cannot create IP address with /0 mask.")
})
# Do not allow assigning a network ID or broadcast address to an interface.
if self.assigned_object:
if self.address.ip == self.address.network:
msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(
ip=self.address.ip
)
if self.address.version == 4 and self.address.prefixlen not in (31, 32):
raise ValidationError(msg)
if self.address.version == 6 and self.address.prefixlen not in (127, 128):
raise ValidationError(msg)
if (
self.address.version == 4 and self.address.ip == self.address.broadcast and
self.address.prefixlen not in (31, 32)
):
msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
ip=self.address.ip
)
raise ValidationError(msg)
# Enforce unique IP space (if applicable)
if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_ips = self.get_duplicates()

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

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

@@ -73,17 +73,12 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
"""
Base form for creating a NetBox objects from CSV data. Used for bulk importing.
"""
id = forms.IntegerField(
label=_('Id'),
required=False,
help_text='Numeric ID of an existing object to update (if not creating a new object)'
)
tags = CSVModelMultipleChoiceField(
label=_('Tags'),
queryset=Tag.objects.all(),
required=False,
to_field_name='slug',
help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")'
help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")')
)
def _get_custom_fields(self, content_type):

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

@@ -59,9 +59,10 @@ class SearchIndex:
@staticmethod
def get_field_value(instance, field_name):
"""
Return the value of the specified model field as a string.
Return the value of the specified model field as a string (or None).
"""
return str(getattr(instance, field_name))
if value := getattr(instance, field_name):
return str(value)
@classmethod
def get_category(cls):

View File

@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup
#
VERSION = '3.7.2'
VERSION = '3.7.6'
# Hostname
HOSTNAME = platform.node()
@@ -160,6 +160,9 @@ RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
SECURE_HSTS_PRELOAD = getattr(configuration, 'SECURE_HSTS_PRELOAD', False)
SECURE_HSTS_SECONDS = getattr(configuration, 'SECURE_HSTS_SECONDS', 0)
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
@@ -571,7 +574,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',

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

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