Compare commits

...

75 Commits

Author SHA1 Message Date
Arthur
d314dac470 #20923: Migrate IPAM views to declarative layouts 2026-03-17 10:25:14 -07:00
Arthur
6294a96199 #20923: Migrate IPAM views to declarative layouts 2026-03-17 10:23:16 -07:00
github-actions
21f78049bc Update source translation strings
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.14) (push) Failing after 30s
CodeQL / Analyze (actions) (push) Failing after 1m4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m10s
CodeQL / Analyze (python) (push) Failing after 1m9s
2026-03-14 05:18:31 +00:00
Jeremy Stretch
e28ed7446c Fixes #21578: Enable assignment of scope object by name when bulk importing prefixes/VLAN groups (#21671) 2026-03-13 16:27:26 -07:00
Jeremy Stretch
9b57512b12 Fixes #21579: Display 'add script' button only if user has sufficient permission (#21628)
CI / build (20.x, 3.12) (push) Failing after 38s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 46s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 53s
* Fixes #21579: Display 'add script' button only if user has sufficient permission

* Check for core.add_managedfile permission too
2026-03-13 22:08:03 +01:00
github-actions
da79cc775d Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 1m7s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m14s
CodeQL / Analyze (python) (push) Failing after 1m11s
2026-03-13 05:20:12 +00:00
Jeremy Stretch
6f5fd26183 Fixes #20077: Fix form field focus bug on Edge
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 1m11s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m16s
CodeQL / Analyze (python) (push) Failing after 1m12s
2026-03-12 14:49:43 -04:00
Jason Novinger
10157394ae Fixes #21651: Disable ordering on MACAddress is_primary column
is_primary is a cached_property, not a database field, so attempting
to order by it raises a FieldError.
2026-03-12 14:48:58 -04:00
Jeremy Stretch
ae0907fb37 Fixes #20934: Fix flicker when navigating in dark mode (#21650) 2026-03-12 09:38:04 -07:00
Martin Hauser
fea6ad61fd fix(virtualization): Hide VM Add Components dropdown without change permission (#21634)
Wrap the VirtualMachine "Add Components" dropdown in a
`virtualization.change_virtualmachine` permission check to match Device
behavior and prevent users without change permission from seeing
component add actions.

Fixes #21580
2026-03-12 09:30:40 -07:00
bctiemann
675e68f276 Merge pull request #21623 from netbox-community/20923-migrate-vpn-views
CI / build (20.x, 3.12) (push) Failing after 19s
CI / build (20.x, 3.13) (push) Failing after 25s
CI / build (20.x, 3.14) (push) Failing after 39s
CodeQL / Analyze (actions) (push) Failing after 1m6s
CodeQL / Analyze (javascript-typescript) (push) Failing after 58s
CodeQL / Analyze (python) (push) Failing after 57s
#20923: Convert `vpn` views to new UI layout
2026-03-12 09:14:48 -04:00
bctiemann
20b907a8c9 Merge pull request #21630 from netbox-community/21114-data-source
#21114 Allow specifying exclude directories for Data Sources
2026-03-12 09:11:12 -04:00
Jason Novinger
8ccb0f7b63 Closes #20923: Migrate wireless app views to declarative UI layouts (#21646)
* #20923: Migrate wireless app views to declarative UI layouts

Convert WirelessLANGroup, WirelessLAN, and WirelessLink detail views
from legacy HTML templates to declarative Python layout definitions.

New files:
- wireless/ui/panels.py: Panel classes for all three model detail views
- templates/wireless/attrs/auth_psk.html: Secret toggle for PSK field
- templates/wireless/panels/wirelesslink_interface_{a,b}.html: Interface
  panels for WirelessLink detail view

Removed:
- templates/wireless/inc/authentication_attrs.html
- templates/wireless/inc/wirelesslink_interface.html

* Consolidate wireless link interface templates into ObjectPanel subclass

Replace duplicate wirelesslink_interface_{a,b}.html templates with a
single shared template and WirelessLinkInterfacePanel(ObjectPanel)
subclass that injects the correct interface via get_context().

* Rename WirelessLANAuthenticationPanel to WirelessAuthenticationPanel

Drop the 'LAN' qualifier since the panel is shared by both WirelessLAN
and WirelessLink views.

* Fix accessor shadowing in WirelessLinkInterfacePanel

Rename __init__ parameter from 'accessor' to 'interface_attr' to avoid
shadowing ObjectPanel.accessor, which would cause super().get_context()
to resolve the wrong context key.

* Use SimpleLayout for WirelessLinkView

Replace explicit Layout with SimpleLayout, which auto-includes plugin
content panels. Remove unused Row, Column, and PluginContentPanel
imports.
2026-03-12 08:55:50 -04:00
bctiemann
068fce4d7c Merge pull request #21608 from netbox-community/21440-oob-ip-import
Fixes #21440: Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
2026-03-12 08:31:40 -04:00
bctiemann
2e4bce2dad Merge pull request #21555 from ITJamie/patch-3
Add changelog message documentation in custom scripts
2026-03-12 08:29:19 -04:00
GeertJohan
dad96c525f Fixes #21618: Preserve cable terminations when bulk-editing cable profile
When `update_terminations(force=True)` is called (e.g. after a profile
change), cache the termination objects from the database before deleting
CableTermination records. Without this, the `a_terminations`/`b_terminations`
properties fall back to querying the (now-empty) DB and return empty lists,
resulting in all terminations being lost.

Also removes a leftover debug print statement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:23:34 -04:00
Martin Hauser
cac3c1221c Closes #21631: Remove duplicate 'created' field in RackReservation table (#21632)
CI / build (20.x, 3.12) (push) Failing after 58s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 45s
CodeQL / Analyze (javascript-typescript) (push) Failing after 48s
CodeQL / Analyze (python) (push) Failing after 50s
2026-03-11 11:49:01 -05:00
Jeremy Stretch
3a9d00a537 Update the lock-threads workflow
CI / build (20.x, 3.13) (push) Failing after 34s
CI / build (20.x, 3.12) (push) Failing after 36s
CI / build (20.x, 3.14) (push) Failing after 52s
CodeQL / Analyze (actions) (push) Failing after 1m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m17s
CodeQL / Analyze (python) (push) Failing after 1m16s
2026-03-11 08:56:39 -04:00
github-actions
4040e4f266 Update source translation strings 2026-03-11 05:19:17 +00:00
Jeremy Stretch
f938309ed9 Second attempt to fix @claude for PRs from forks (#21633)
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 48s
CodeQL / Analyze (javascript-typescript) (push) Failing after 54s
CodeQL / Analyze (python) (push) Failing after 57s
2026-03-10 10:35:28 -07:00
Arthur
86f6de40d2 add docs and tests 2026-03-10 08:58:07 -07:00
Arthur
83c6149e49 #21114 Allow specifying exclude directories for Data Sources 2026-03-10 08:46:47 -07:00
Jeremy Stretch
98d898aba9 Fix the Claude action for external PRs (#21629) 2026-03-10 08:26:36 -07:00
Arthur Hanson
07bb6aa365 #20923: Migrate Users object to declarative layouts (#21568)
This continues the migration of object views in the user app to NetBox v4.5’s declarative layouts.
Replace legacy object view templates with declarative layouts for:
   - Users
   - Groups
   - API Tokens
   - Permissions
   - Owner Groups
   - Owners
2026-03-10 16:04:24 +01:00
pobradovic08
f3c34b30ec Fixes #21402: Prefetch device_type and manufacturer for brief mode API responses (#21616)
* Fixes #21402: Prefetch device_type and manufacturer for brief mode API responses

Add select_related for device_type__manufacturer on the DeviceViewSet
queryset to prevent N+1 queries when rendering unnamed devices in brief
mode.

* Use prefetch_related instead of select_related for device_type__manufacturer
2026-03-10 10:38:17 -04:00
github-actions
2281889e9d Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 1m8s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m13s
CodeQL / Analyze (python) (push) Failing after 1m17s
2026-03-10 05:18:47 +00:00
Jeremy Stretch
b19d0d61f4 Delete unused template 2026-03-09 15:48:04 -04:00
Jeremy Stretch
d64c4d75f8 #20923: Convert vpn views to new UI layout 2026-03-09 15:25:25 -04:00
Arthur Hanson
b5bd8905ca #21330 optimize the assignment of tags when saving an object (#21595)
CI / build (20.x, 3.12) (push) Failing after 46s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 44s
CodeQL / Analyze (javascript-typescript) (push) Failing after 45s
CodeQL / Analyze (python) (push) Failing after 49s
* #21330 optimize object tag creation

* ruff fixes

* optimize

* review changes

* fix

* Update netbox/extras/managers.py

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-03-09 14:11:14 -04:00
Jeremy Stretch
cb5521f818 Closes #21468: copy_safe_request() should retain non-sensitive HTTP request headers (#21577)
- Define `HTTP_REQUEST_META_SENSITIVE` to serve as a blacklist for
  known-sensitive headers
- Modify `copy_safe_request()` to copy all non-sensitive headers
  (ignoring any not defined as strings)
- Add the `CopySafeRequestTests` test suite
2026-03-09 16:54:00 +01:00
Jeremy Stretch
3cb854b7d5 Closes #21611: Replace calls to .count() with .exists() (#21612)
Replace two boolean evaluations of .count() with .exists()
2026-03-09 16:46:38 +01:00
Jeremy Stretch
d980837da0 Fixes #20385: Ensure GraphQL API respects MAX_PAGE_SIZE (#21617)
- Extend `apply_pagination()` to check for and apply `MAX_PAGE_SIZE`
- Add a test
2026-03-09 14:58:23 +01:00
github-actions
5c19afc07c Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 24s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m9s
CodeQL / Analyze (python) (push) Failing after 1m17s
2026-03-07 05:14:28 +00:00
Jeremy Stretch
67defb3228 Fixes #21531: Fix search functionality for location when combined with other filters (#21599)
CodeQL / Analyze (actions) (push) Failing after 5s
CI / build (20.x, 3.12) (push) Failing after 20s
CodeQL / Analyze (javascript-typescript) (push) Failing after 4s
CI / build (20.x, 3.13) (push) Failing after 15s
CodeQL / Analyze (python) (push) Failing after 3s
CI / build (20.x, 3.14) (push) Failing after 14s
2026-03-06 11:54:10 -06:00
Martin Hauser
cca4cc61b6 Fixes #21512: Fix GraphQL filtering for device, module components, templates (#21602) 2026-03-06 11:23:45 -06:00
Jamie (Bear) Murphy
9b0c6110bb Clarify optional changelog message in custom-scripts
Added comment to clarify optional changelog message.
2026-03-06 17:13:52 +00:00
Martin Hauser
758b230403 docs(webhooks): Update context variables and example payload (#21607)
Clarify webhook context variable names and event types.
Replace `model` with `object_type`, update event values to match actual
output (`created` vs. `create`), and refresh example JSON to reflect the
current API response format, including new fields like `display` and
`display_url`.

Fixes #21489
2026-03-06 09:04:30 -08:00
Jeremy Stretch
8ea33df148 Fixes #20915: Ensure preferred language is applied during SSO login (#21590) 2026-03-06 10:00:33 -06:00
Jeremy Stretch
c86210f024 Fixes #21440: Avoid erroneously clearing primary/OOB IP assignments during bulk import/update 2026-03-06 10:48:06 -05:00
Jeremy Stretch
685c1afdcf Update CONTRIBUTING.md (#21606)
- Enforce a limit of three open PRs per community contributor
- Clarify AI content policy
- Misc rewording
2026-03-06 16:32:19 +01:00
Martin Hauser
d62a0d7d8d fix(extras): Add missing COOKIES and method to NetBoxFakeRequest
Populate COOKIES dict and set method to POST in runscript command's
NetBoxFakeRequest. Ensures the fake request object more closely mimics
a real Django request, preventing potential issues with code expecting
these attributes.

Fixes #21486
2026-03-06 09:52:26 -05:00
bctiemann
1c527366c9 Merge pull request #21597 from netbox-community/21012-interface-vlans-list
Fixes #21012: Ensure all tagged VLANs assigned to an interface are listed under the interface detail UI view
2026-03-06 09:18:33 -05:00
Jeremy Stretch
e1684fb645 Display the interface's untagged VLAN in the attributes table 2026-03-06 07:37:46 -05:00
Jeremy Stretch
969ae81574 Fixes #21380: Fix display of the background workers list on small screens (#21598)
CodeQL / Analyze (actions) (push) Failing after 14s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7s
CI / build (20.x, 3.13) (push) Failing after 31s
CI / build (20.x, 3.12) (push) Failing after 33s
CodeQL / Analyze (python) (push) Failing after 6s
CI / build (20.x, 3.14) (push) Failing after 31s
Wrap the table in a `.table-responsive` to enable horizontal scrolling
within the table body.
2026-03-06 07:45:01 +01:00
github-actions
baec71fcaf Update source translation strings 2026-03-06 05:17:32 +00:00
Jeremy Stretch
44abeeff5a Fixes #21012: Ensure all tagged VLANs assigned to an interface are listed under the interface detail UI view 2026-03-05 16:35:31 -05:00
Martin Hauser
93e01d5b07 fix(dcim): Correct object type for child Site Group actions
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.14) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 9s
CodeQL / Analyze (javascript-typescript) (push) Failing after 10s
CodeQL / Analyze (python) (push) Failing after 12s
Replace `dcim.Region` with `dcim.SiteGroup` in child Site Group actions
for the DCIM view. Ensures the correct model is referenced when adding
child Site Groups, improving functionality and aligning with the
expected behavior.

Fixes #21586
2026-03-05 13:59:18 -05:00
Jeremy Stretch
fa5f9430fc Fixes #20468: Fix range lookups for numeric GraphQL filters (#21589)
CodeQL / Analyze (actions) (push) Failing after 6s
CI / build (20.x, 3.12) (push) Failing after 21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 4s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 4s
* Fixes #20468: Fix range lookups for numeric GraphQL filters

* Update netbox/netbox/tests/test_graphql.py

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-03-05 17:10:49 +01:00
Jeremy Stretch
351066c73f Limit auto-review workflow to GitHub org members (#21570) 2026-03-05 08:06:43 -08:00
bctiemann
e6db3f75ea Merge pull request #21588 from netbox-community/19867-preserve-per_page-param
Fixes #19867: Retain the `per_page` URL parameter after editing an object
2026-03-05 09:56:32 -05:00
Jeremy Stretch
04244e188f #20923: Migrate DCIM view templates (#21372)
* Permit passing template_name to Panel instance

* Define UI layout for ModuleType view

* Define UI layout for DeviceRole view

* Define UI layout for Platform view

* Define UI layout for Module view

* Misc cleanup

* Linkify module bay
2026-03-05 08:43:46 -05:00
Jeremy Stretch
eaad5cc26f Fixes #19867: Retain the per_page URL parameter after editing an object 2026-03-05 08:26:47 -05:00
Jason Novinger
a1d82e45a0 Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 1m4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m4s
CodeQL / Analyze (python) (push) Failing after 1m5s
Add yarn resolutions to force patched versions of two transitive
dependencies flagged by dependabot:

- minimatch 3.1.2 → 3.1.5 (GHSA-7r86-cg39-jmmj, high severity ReDoS)
- markdown-it 14.1.0 → 14.1.1 (CVE-2026-2327, medium severity ReDoS)
2026-03-04 16:08:02 +01:00
github-actions
e4f7f080b3 Update source translation strings
CodeQL / Analyze (javascript-typescript) (push) Failing after 57s
CodeQL / Analyze (actions) (push) Failing after 1m0s
CodeQL / Analyze (python) (push) Failing after 58s
2026-03-04 05:17:48 +00:00
Jason Novinger
2c1ee47b2e Reject reserved action names in register_model_actions()
CI / build (20.x, 3.14) (push) Failing after 18s
CI / build (20.x, 3.13) (push) Failing after 20s
CI / build (20.x, 3.12) (push) Failing after 23s
2026-03-03 16:56:15 -06:00
Jason Novinger
7ef77a0ecf Refactor SplitMultiSelectWidget to use class attributes for widget classes 2026-03-03 16:40:52 -06:00
Jason Novinger
975910ace3 Rebuild frontend assets after rebase onto feature 2026-03-03 16:12:14 -06:00
Jason Novinger
f4b111dd8a Remove stale comment in RegisteredActionsWidget 2026-03-03 16:10:02 -06:00
Jason Novinger
a117cc7526 Prevent duplicate action registration in register_model_actions() 2026-03-03 16:10:02 -06:00
Jason Novinger
8a72b3c61c Fix shared action pre-selection and additional actions leakage on edit 2026-03-03 16:10:02 -06:00
Jason Novinger
e2537305b2 Add RESERVED_ACTIONS constant and fix dedup in registered actions
- Define RESERVED_ACTIONS in users/constants.py for the four built-in
  permission actions (view, add, change, delete)
- Replace hardcoded action lists in ObjectPermissionForm with the constant
- Fix duplicate action names in clean() when the same action is registered
  across multiple models (e.g. render_config for Device and VirtualMachine)
- Fix template substring matching bug in objectpermission.html detail view
  by passing RESERVED_ACTIONS through view context for proper list membership
2026-03-03 16:10:02 -06:00
Jason Novinger
3bc60303fe Add documentation for custom model actions
- Add plugin development guide for registering custom actions
- Update admin permissions docs to mention custom actions UI
- Add docstrings to ModelAction and register_model_actions
2026-03-03 16:10:02 -06:00
Jason Novinger
036ff7082f Hide custom actions field when no applicable models selected
The entire field row is now hidden when no selected object types
have registered custom actions, avoiding an empty "Custom actions"
label.
2026-03-03 16:10:02 -06:00
Jason Novinger
6e8fb5c262 Refine registered actions widget UI
- Use verbose labels (App | Model) for action group headers
- Simplify template layout with h5 headers instead of cards
- Consolidate Standard/Custom/Additional Actions into single Actions fieldset
2026-03-03 16:10:02 -06:00
Jason Novinger
ba3a32051d Add tests for ModelAction and register_model_actions 2026-03-03 16:10:02 -06:00
Jason Novinger
63fe14cf6b Register custom actions for DataSource, Device, and VirtualMachine 2026-03-03 16:10:02 -06:00
Jason Novinger
2b70c06e67 Add JavaScript for registered actions show/hide 2026-03-03 16:10:02 -06:00
Jason Novinger
004e2d6d3c Integrate registered actions into ObjectPermissionForm 2026-03-03 16:09:49 -06:00
Jason Novinger
e5439f4eb8 Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget 2026-03-03 16:09:49 -06:00
Jason Novinger
9de5a0c584 Add ModelAction and register_model_actions() API for custom permission actions 2026-03-03 16:09:49 -06:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 1m12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m15s
CodeQL / Analyze (python) (push) Failing after 1m15s
* Add stronger deprecation warning on use of housekeeping management command

* Add stronger deprecation warning on use of housekeeping management command

* Rework deprecation warning to use FutureWarning (not DeprecationWarning as that is ignored in non-dev environments).
2026-03-03 16:12:39 -05:00
Jeremy Stretch
53ea48efa9 Merge branch 'main' into feature 2026-03-03 15:40:46 -05:00
Jamie (Bear) Murphy
1be917fb90 Add changelog message documentation in custom scripts
Add changelog message documentation in custom scripts
2026-03-03 13:10:04 +00:00
Jeremy Stretch
1a404f5c0f Merge branch 'main' into feature
CI / build (20.x, 3.12) (push) Failing after 19s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 13s
CodeQL / Analyze (actions) (push) Failing after 1m2s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m9s
CodeQL / Analyze (python) (push) Failing after 1m10s
2026-02-25 17:07:26 -05:00
bctiemann
3320e07b70 Closes #21284: Add deprecation note to webhooks documentation (#21491)
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 41s
CodeQL / Analyze (actions) (push) Failing after 1m16s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m24s
CodeQL / Analyze (python) (push) Failing after 1m25s
* Add searchable deprecation comments on request_id and username fields in EventContext

* Add deprecation note in webhooks documentation

* Expand deprecation note/warning

* Add version number to deprecation warning

* Add deprecation warning to two other places
2026-02-20 19:52:42 +01:00
25 changed files with 552 additions and 31 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
* **Change** - Modify an existing object
* **Delete** - Delete an existing object
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
Some models have registered custom actions that appear as checkboxes when creating or editing a permission. These are grouped by model under "Custom actions" in the permission form. Additional custom actions (such as those not yet registered or for backwards compatibility) can be entered manually in the "Additional actions" field.
!!! note
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
+5
View File
@@ -31,6 +31,11 @@ The following data is available as context for Jinja2 templates:
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
+5
View File
@@ -88,3 +88,8 @@ The following context variables are available in to the text and link templates.
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
+36
View File
@@ -0,0 +1,36 @@
# Custom Model Actions
Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
## Registering Model Actions
To register custom actions for a model, call `register_model_actions()` in your plugin's `ready()` method:
```python
# __init__.py
from netbox.plugins import PluginConfig
class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...
def ready(self):
super().ready()
from utilities.permissions import ModelAction, register_model_actions
from .models import MyModel
register_model_actions(MyModel, [
ModelAction('sync', help_text='Synchronize data from external source'),
ModelAction('export', help_text='Export data to external system'),
])
config = MyPluginConfig
```
Once registered, these actions will appear grouped under your model's name when creating or editing an ObjectPermission that includes your model as an object type.
::: utilities.permissions.ModelAction
::: utilities.permissions.register_model_actions
+5
View File
@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
+1
View File
@@ -151,6 +151,7 @@ nav:
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
- Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'
+7
View File
@@ -25,12 +25,19 @@ class CoreConfig(AppConfig):
from core.checks import check_duplicate_indexes # noqa: F401
from netbox import context_managers # noqa: F401
from netbox.models.features import register_models
from utilities.permissions import ModelAction, register_model_actions
from . import data_backends, events, search # noqa: F401
from .models import DataSource
# Register models
register_models(*self.get_models())
# Register custom permission actions
register_model_actions(DataSource, [
ModelAction('sync', help_text=_('Synchronize data from remote source')),
])
# Register core events
EventType(OBJECT_CREATED, _('Object created')).register()
EventType(OBJECT_UPDATED, _('Object updated')).register()
+8
View File
@@ -8,8 +8,11 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
from django.utils.translation import gettext as _
from netbox.models.features import register_models
from utilities.counters import connect_counters
from utilities.permissions import ModelAction, register_model_actions
from . import search, signals # noqa: F401
from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
@@ -17,6 +20,11 @@ class DCIMConfig(AppConfig):
# Register models
register_models(*self.get_models())
# Register custom permission actions
register_model_actions(Device, [
ModelAction('render_config', help_text=_('Render device configuration')),
])
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
'_rack': 'rack',
@@ -1,3 +1,4 @@
import warnings
from datetime import timedelta
from importlib import import_module
@@ -17,11 +18,12 @@ class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options):
self.stdout.write(
warnings.warn(
"\n\nDEPRECATION WARNING\n"
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.",
self.style.WARNING
"will be removed in a future release.\n",
category=FutureWarning,
)
config = Config()
+1
View File
@@ -28,6 +28,7 @@ registry = Registry({
'denormalized_fields': collections.defaultdict(list),
'event_types': dict(),
'filtersets': dict(),
'model_actions': collections.defaultdict(list),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,10 +1,17 @@
import { initClearField } from './clearField';
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initRegisteredActions } from './registeredActions';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
for (const func of [
initFormElements,
initSpeedSelector,
initFilterModifiers,
initClearField,
initRegisteredActions,
]) {
func();
}
}
@@ -0,0 +1,61 @@
import { getElements } from '../util';
/**
* Show/hide registered action checkboxes based on selected object_types.
*/
export function initRegisteredActions(): void {
const actionsContainer = document.getElementById('id_registered_actions_container');
const selectedList = document.getElementById('id_object_types_1') as HTMLSelectElement;
if (!actionsContainer || !selectedList) {
return;
}
function updateVisibility(): void {
const selectedModels = new Set<string>();
// Get model keys from selected options
for (const option of Array.from(selectedList.options)) {
const modelKey = option.dataset.modelKey;
if (modelKey) {
selectedModels.add(modelKey);
}
}
// Show/hide action groups
const groups = actionsContainer!.querySelectorAll('.model-actions');
let anyVisible = false;
groups.forEach(group => {
const modelKey = group.getAttribute('data-model');
const visible = modelKey !== null && selectedModels.has(modelKey);
(group as HTMLElement).style.display = visible ? 'block' : 'none';
if (visible) {
anyVisible = true;
}
});
// Show/hide "no actions" message
const noActionsMsg = document.getElementById('no-custom-actions-message');
if (noActionsMsg) {
noActionsMsg.style.display = anyVisible ? 'none' : 'block';
}
// Hide the entire field row when no actions are visible
const fieldRow = actionsContainer!.closest('.field-row, .mb-3');
if (fieldRow) {
(fieldRow as HTMLElement).style.display = anyVisible ? '' : 'none';
}
}
// Initial update
updateVisibility();
// Listen to move button clicks
for (const btn of getElements<HTMLButtonElement>('.move-option')) {
btn.addEventListener('click', () => {
// Wait for DOM update
setTimeout(updateVisibility, 50);
});
}
}
@@ -46,6 +46,14 @@
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.can_delete %}</td>
</tr>
{% for action in object.actions %}
{% if action not in reserved_actions %}
<tr>
<th scope="row">{{ action }}</th>
<td>{% checkmark True %}</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
<div class="card">
+4
View File
@@ -10,6 +10,10 @@ OBJECTPERMISSION_OBJECT_TYPES = (
CONSTRAINT_TOKEN_USER = '$user'
# Built-in actions that receive special handling (dedicated checkboxes, model properties)
# and should not be registered as custom model actions.
RESERVED_ACTIONS = ('view', 'add', 'change', 'delete')
# API tokens
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
TOKEN_KEY_LENGTH = 12
+82 -14
View File
@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.config import get_config
from netbox.preferences import PREFERENCES
from netbox.registry import registry
from users.choices import TokenVersionChoices
from users.constants import *
from users.models import *
@@ -25,7 +26,7 @@ from utilities.forms.fields import (
JSONField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
from utilities.forms.widgets import DateTimePicker, ObjectTypeSplitMultiSelectWidget, RegisteredActionsWidget
from utilities.permissions import qs_filter_from_constraints
from utilities.string import title
@@ -325,7 +326,7 @@ class ObjectPermissionForm(forms.ModelForm):
object_types = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.all(),
widget=SplitMultiSelectWidget(
widget=ObjectTypeSplitMultiSelectWidget(
choices=get_object_types_choices
),
help_text=_('Select the types of objects to which the permission will apply.')
@@ -342,6 +343,11 @@ class ObjectPermissionForm(forms.ModelForm):
can_delete = forms.BooleanField(
required=False
)
registered_actions = forms.MultipleChoiceField(
required=False,
widget=RegisteredActionsWidget(),
label=_('Custom actions'),
)
actions = SimpleArrayField(
label=_('Additional actions'),
base_field=forms.CharField(),
@@ -370,8 +376,11 @@ class ObjectPermissionForm(forms.ModelForm):
fieldsets = (
FieldSet('name', 'description', 'enabled'),
FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
FieldSet('object_types', name=_('Objects')),
FieldSet(
'can_view', 'can_add', 'can_change', 'can_delete', 'registered_actions', 'actions',
name=_('Actions')
),
FieldSet('groups', 'users', name=_('Assignment')),
FieldSet('constraints', name=_('Constraints')),
)
@@ -385,6 +394,22 @@ class ObjectPermissionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Build PK to model key mapping for object_types widget
pk_to_model_key = {
ot.pk: f'{ot.app_label}.{ot.model}'
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES)
}
self.fields['object_types'].widget.set_model_key_map(pk_to_model_key)
# Configure registered_actions widget and field choices
model_actions = dict(registry['model_actions'])
self.fields['registered_actions'].widget.model_actions = model_actions
choices = []
for model_key, actions in model_actions.items():
for action in actions:
choices.append((f'{model_key}.{action.name}', action.name))
self.fields['registered_actions'].choices = choices
# Make the actions field optional since the form uses it only for non-CRUD actions
self.fields['actions'].required = False
@@ -394,11 +419,31 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields['groups'].initial = self.instance.groups.values_list('id', flat=True)
self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
# Check the appropriate checkboxes when editing an existing ObjectPermission
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
# Work with a copy to avoid mutating the instance
remaining_actions = list(self.instance.actions)
# Check the appropriate CRUD checkboxes
for action in RESERVED_ACTIONS:
if action in remaining_actions:
self.fields[f'can_{action}'].initial = True
self.instance.actions.remove(action)
remaining_actions.remove(action)
# Pre-select registered actions
selected_registered = []
consumed_actions = set()
for ct in self.instance.object_types.all():
model_key = f'{ct.app_label}.{ct.model}'
if model_key in model_actions:
for ma in model_actions[model_key]:
if ma.name in remaining_actions:
selected_registered.append(f'{model_key}.{ma.name}')
consumed_actions.add(ma.name)
self.fields['registered_actions'].initial = selected_registered
# Remaining actions go to the additional actions field
self.initial['actions'] = [
a for a in remaining_actions if a not in consumed_actions
]
# Populate initial data for a new ObjectPermission
elif self.initial:
@@ -408,7 +453,7 @@ class ObjectPermissionForm(forms.ModelForm):
if isinstance(self.initial['actions'], str):
self.initial['actions'] = [self.initial['actions']]
if cloned_actions := self.initial['actions']:
for action in ['view', 'add', 'change', 'delete']:
for action in RESERVED_ACTIONS:
if action in cloned_actions:
self.fields[f'can_{action}'].initial = True
self.initial['actions'].remove(action)
@@ -420,15 +465,38 @@ class ObjectPermissionForm(forms.ModelForm):
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
object_types = self.cleaned_data.get('object_types', [])
registered_actions = self.cleaned_data.get('registered_actions', [])
constraints = self.cleaned_data.get('constraints')
# Build set of selected model keys for validation
selected_models = {f'{ct.app_label}.{ct.model}' for ct in object_types}
# Validate registered actions match selected object_types and collect action names
final_actions = []
for action_key in registered_actions:
model_key, action_name = action_key.rsplit('.', 1)
if model_key not in selected_models:
raise forms.ValidationError({
'registered_actions': _(
'Action "{action}" is for {model} which is not selected.'
).format(action=action_name, model=model_key)
})
if action_name not in final_actions:
final_actions.append(action_name)
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
self.cleaned_data['actions'] = list()
for action in ['view', 'add', 'change', 'delete']:
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
self.cleaned_data['actions'].append(action)
for action in RESERVED_ACTIONS:
if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
final_actions.append(action)
# Add additional/manual actions
if additional_actions := self.cleaned_data.get('actions'):
for action in additional_actions:
if action not in final_actions:
final_actions.append(action)
self.cleaned_data['actions'] = final_actions
# At least one action must be specified
if not self.cleaned_data['actions']:
+6
View File
@@ -10,6 +10,7 @@ from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .constants import RESERVED_ACTIONS
from .models import Group, ObjectPermission, Owner, OwnerGroup, Token, User
#
@@ -214,6 +215,11 @@ class ObjectPermissionView(generic.ObjectView):
queryset = ObjectPermission.objects.all()
template_name = 'users/objectpermission.html'
def get_extra_context(self, request, instance):
return {
'reserved_actions': RESERVED_ACTIONS,
}
@register_model_view(ObjectPermission, 'add', detail=False)
@register_model_view(ObjectPermission, 'edit')
@@ -1,3 +1,4 @@
from .actions import *
from .apiselect import *
from .datetime import *
from .misc import *
+39
View File
@@ -0,0 +1,39 @@
from django import forms
from django.apps import apps
__all__ = (
'RegisteredActionsWidget',
)
class RegisteredActionsWidget(forms.CheckboxSelectMultiple):
"""
Widget rendering checkboxes for registered model actions.
Groups actions by model with data attributes for JS show/hide.
"""
template_name = 'widgets/registered_actions.html'
def __init__(self, *args, model_actions=None, **kwargs):
super().__init__(*args, **kwargs)
self.model_actions = model_actions or {}
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
model_actions_with_labels = {}
for model_key, actions in self.model_actions.items():
app_label, model_name = model_key.split('.')
try:
model = apps.get_model(app_label, model_name)
app_config = apps.get_app_config(app_label)
label = f"{app_config.verbose_name} | {model._meta.verbose_name.title()}"
except LookupError:
label = model_key
model_actions_with_labels[model_key] = {
'label': label,
'actions': actions,
}
context['widget']['model_actions'] = model_actions_with_labels
context['widget']['value'] = value or []
return context
+50 -2
View File
@@ -9,6 +9,7 @@ __all__ = (
'ClearableSelect',
'ColorSelect',
'HTMXSelect',
'ObjectTypeSplitMultiSelectWidget',
'SelectWithPK',
'SplitMultiSelectWidget',
)
@@ -150,14 +151,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
be enabled only if the order of the selected choices is significant.
"""
template_name = 'widgets/splitmultiselect.html'
available_widget_class = AvailableOptions
selected_widget_class = SelectedOptions
def __init__(self, choices, attrs=None, ordering=False):
widgets = [
AvailableOptions(
self.available_widget_class(
attrs={'size': 8},
choices=choices
),
SelectedOptions(
self.selected_widget_class(
attrs={'size': 8, 'class': 'select-all'},
choices=choices
),
@@ -180,3 +183,48 @@ class SplitMultiSelectWidget(forms.MultiWidget):
def value_from_datadict(self, data, files, name):
# Return only the choices from the SelectedOptions widget
return super().value_from_datadict(data, files, name)[1]
#
# ObjectType-specific widgets for ObjectPermissionForm
#
class ObjectTypeSelectMultiple(SelectMultipleBase):
"""
SelectMultiple that adds data-model-key attribute to options for JS targeting.
"""
pk_to_model_key = None
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(name, value, label, selected, index, subindex, attrs)
if self.pk_to_model_key:
model_key = self.pk_to_model_key.get(value) or self.pk_to_model_key.get(str(value))
if model_key:
option['attrs']['data-model-key'] = model_key
return option
class ObjectTypeAvailableOptions(ObjectTypeSelectMultiple):
include_selected = False
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['attrs']['required'] = False
return context
class ObjectTypeSelectedOptions(ObjectTypeSelectMultiple):
include_selected = True
class ObjectTypeSplitMultiSelectWidget(SplitMultiSelectWidget):
"""
SplitMultiSelectWidget that adds data-model-key attributes to options.
Used by ObjectPermissionForm to enable JS show/hide of custom actions.
"""
available_widget_class = ObjectTypeAvailableOptions
selected_widget_class = ObjectTypeSelectedOptions
def set_model_key_map(self, pk_to_model_key):
for widget in self.widgets:
widget.pk_to_model_key = pk_to_model_key
+47 -2
View File
@@ -1,19 +1,64 @@
from dataclasses import dataclass
from django.apps import apps
from django.conf import settings
from django.db.models import Q
from django.db.models import Model, Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
from netbox.registry import registry
from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS
__all__ = (
'ModelAction',
'get_permission_for_model',
'permission_is_exempt',
'qs_filter_from_constraints',
'register_model_actions',
'resolve_permission',
'resolve_permission_type',
)
@dataclass
class ModelAction:
"""
Represents a custom permission action for a model.
Attributes:
name: The action identifier (e.g. 'sync', 'render_config')
help_text: Optional description displayed in the ObjectPermission form
"""
name: str
help_text: str = ''
def __hash__(self):
return hash(self.name)
def __eq__(self, other):
if isinstance(other, ModelAction):
return self.name == other.name
return self.name == other
def register_model_actions(model: type[Model], actions: list[ModelAction | str]):
"""
Register custom permission actions for a model. These actions will appear as
checkboxes in the ObjectPermission form when the model is selected.
Args:
model: The model class to register actions for
actions: A list of ModelAction instances or action name strings
"""
label = f'{model._meta.app_label}.{model._meta.model_name}'
for action in actions:
if isinstance(action, str):
action = ModelAction(name=action)
if action.name in RESERVED_ACTIONS:
raise ValueError(f"'{action.name}' is a reserved action and cannot be registered.")
if action not in registry['model_actions'][label]:
registry['model_actions'][label].append(action)
def get_permission_for_model(model, action):
"""
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
@@ -0,0 +1,28 @@
{% load i18n %}
<div class="registered-actions-container" id="id_registered_actions_container">
{% for model_key, model_data in widget.model_actions.items %}
<div class="model-actions" data-model="{{ model_key }}" style="display: none;">
<h5 class="mb-2 mt-3">{{ model_data.label }}</h5>
{% for action in model_data.actions %}
<div class="form-check">
<input type="checkbox"
class="form-check-input"
name="{{ widget.name }}"
value="{{ model_key }}.{{ action.name }}"
id="id_{{ widget.name }}_{{ forloop.parentloop.counter }}_{{ forloop.counter }}"
{% if model_key|add:"."|add:action.name in widget.value %}checked{% endif %}>
<label class="form-check-label" for="id_{{ widget.name }}_{{ forloop.parentloop.counter }}_{{ forloop.counter }}">
{{ action.name }}
{% if action.help_text %}
<small class="text-muted ms-1">{{ action.help_text }}</small>
{% endif %}
</label>
</div>
{% endfor %}
</div>
{% empty %}
<p class="text-muted" id="no-custom-actions-message">
{% trans "No custom actions registered." %}
</p>
{% endfor %}
</div>
+126
View File
@@ -0,0 +1,126 @@
from django.test import TestCase
from core.models import ObjectType
from dcim.models import Device, Site
from netbox.registry import registry
from users.forms.model_forms import ObjectPermissionForm
from users.models import ObjectPermission
from utilities.permissions import ModelAction, register_model_actions
from virtualization.models import VirtualMachine
class ModelActionTest(TestCase):
def test_hash(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different help')
self.assertEqual(hash(action1), hash(action2))
def test_equality_with_model_action(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different help')
action3 = ModelAction(name='merge')
self.assertEqual(action1, action2)
self.assertNotEqual(action1, action3)
def test_equality_with_string(self):
action = ModelAction(name='sync')
self.assertEqual(action, 'sync')
self.assertNotEqual(action, 'merge')
def test_usable_in_set(self):
action1 = ModelAction(name='sync')
action2 = ModelAction(name='sync', help_text='Different')
action3 = ModelAction(name='merge')
actions = {action1, action2, action3}
self.assertEqual(len(actions), 2)
class RegisterModelActionsTest(TestCase):
def setUp(self):
self.original_actions = dict(registry['model_actions'])
def tearDown(self):
registry['model_actions'].clear()
registry['model_actions'].update(self.original_actions)
def test_register_model_action_objects(self):
register_model_actions(Site, [
ModelAction('test_action', help_text='Test help'),
])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 1)
self.assertEqual(actions[0].name, 'test_action')
self.assertEqual(actions[0].help_text, 'Test help')
def test_register_string_actions(self):
register_model_actions(Site, ['action1', 'action2'])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertIsInstance(actions[0], ModelAction)
self.assertEqual(actions[0].name, 'action1')
self.assertEqual(actions[1].name, 'action2')
def test_register_mixed_actions(self):
register_model_actions(Site, [
ModelAction('with_help', help_text='Has help'),
'without_help',
])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertEqual(actions[0].help_text, 'Has help')
self.assertEqual(actions[1].help_text, '')
def test_multiple_registrations_append(self):
register_model_actions(Site, [ModelAction('first')])
register_model_actions(Site, [ModelAction('second')])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 2)
self.assertEqual(actions[0].name, 'first')
self.assertEqual(actions[1].name, 'second')
def test_duplicate_registration_ignored(self):
register_model_actions(Site, [ModelAction('sync')])
register_model_actions(Site, [ModelAction('sync', help_text='Different help')])
actions = registry['model_actions']['dcim.site']
self.assertEqual(len(actions), 1)
def test_reserved_action_rejected(self):
for action_name in ('view', 'add', 'change', 'delete'):
with self.assertRaises(ValueError):
register_model_actions(Site, [ModelAction(action_name)])
class ObjectPermissionFormTest(TestCase):
def setUp(self):
self.original_actions = dict(registry['model_actions'])
def tearDown(self):
registry['model_actions'].clear()
registry['model_actions'].update(self.original_actions)
def test_shared_action_preselection(self):
register_model_actions(Device, [ModelAction('render_config')])
register_model_actions(VirtualMachine, [ModelAction('render_config')])
device_ct = ObjectType.objects.get_for_model(Device)
vm_ct = ObjectType.objects.get_for_model(VirtualMachine)
permission = ObjectPermission.objects.create(
name='Test Permission',
actions=['view', 'render_config'],
)
permission.object_types.set([device_ct, vm_ct])
form = ObjectPermissionForm(instance=permission)
initial = form.fields['registered_actions'].initial
self.assertIn('dcim.device.render_config', initial)
self.assertIn('virtualization.virtualmachine.render_config', initial)
# Should not leak into the additional actions field
self.assertEqual(form.initial['actions'], [])
permission.delete()
+8
View File
@@ -5,8 +5,11 @@ class VirtualizationConfig(AppConfig):
name = 'virtualization'
def ready(self):
from django.utils.translation import gettext as _
from netbox.models.features import register_models
from utilities.counters import connect_counters
from utilities.permissions import ModelAction, register_model_actions
from . import search, signals # noqa: F401
from .models import VirtualMachine
@@ -16,3 +19,8 @@ class VirtualizationConfig(AppConfig):
# Register counters
connect_counters(VirtualMachine)
# Register custom permission actions
register_model_actions(VirtualMachine, [
ModelAction('render_config', help_text=_('Render VM configuration')),
])